diff --git a/.gitignore b/.gitignore index 6938c5f15..c594a1ce2 100644 --- a/.gitignore +++ b/.gitignore @@ -269,3 +269,8 @@ docs/Getting-Started/Samples.md # Dump Folder Dump +examples/certs/*-public.pem +examples/certs/*-private.pem +tests/certs/* +/examples/certs +examples/Authentication/certs/* diff --git a/.vscode/settings.json b/.vscode/settings.json index b5be5ef8e..4dd3cd280 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -38,5 +38,13 @@ "javascript.format.insertSpaceBeforeFunctionParenthesis": false, "[yaml]": { "editor.tabSize": 2 + }, + "markdownlint.config": { + "default": true, + "MD045": false, + "MD033": false, + "MD026": { + "punctuation": ".,;:" + } } } \ No newline at end of file diff --git a/README.md b/README.md index 3ffdf9f6f..30dcb52fe 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ -

+

-

+

[![MIT licensed](https://img.shields.io/badge/license-MIT-blue.svg)](https://raw.githubusercontent.com/Badgerati/Pode/master/LICENSE.txt) [![Documentation](https://img.shields.io/github/v/release/badgerati/pode?label=docs&logo=readthedocs&logoColor=white)](https://badgerati.github.io/Pode) @@ -53,36 +53,37 @@ Then navigate to `http://127.0.0.1:8000` in your browser. ## 🚀 Features -* Cross-platform using PowerShell Core (with support for PS5) -* Docker support, including images for ARM/Raspberry Pi -* Azure Functions, AWS Lambda, and IIS support -* OpenAPI specification version 3.0.x and 3.1.0 -* OpenAPI documentation with Swagger, Redoc, RapidDoc, StopLight, OpenAPI-Explorer and RapiPdf -* Listen on a single or multiple IP(v4/v6) address/hostnames -* Cross-platform support for HTTP(S), WS(S), SSE, SMTP(S), and TCP(S) -* Host REST APIs, Web Pages, and Static Content (with caching) -* Support for custom error pages -* Request and Response compression using GZip/Deflate -* Multi-thread support for incoming requests -* Inbuilt template engine, with support for third-parties -* Async timers for short-running repeatable processes -* Async scheduled tasks using cron expressions for short/long-running processes -* Supports logging to CLI, Files, and custom logic for other services like LogStash -* Cross-state variable access across multiple runspaces -* Restart the server via file monitoring, or defined periods/times -* Ability to allow/deny requests from certain IP addresses and subnets -* Basic rate limiting for IP addresses and subnets -* Middleware and Sessions on web servers, with Flash message and CSRF support -* Authentication on requests, such as Basic, Windows and Azure AD -* Authorisation support on requests, using Roles, Groups, Scopes, etc. -* Support for dynamically building Routes from Functions and Modules -* Generate/bind self-signed certificates -* Secret management support to load secrets from vaults -* Support for File Watchers -* In-memory caching, with optional support for external providers (such as Redis) -* (Windows) Open the hosted server as a desktop application -* FileBrowsing support -* Localization (i18n) in Arabic, German, Spanish, France, Italian, Japanese, Korean, Polish, Portuguese, and Chinese +- ✅ Cross-platform using PowerShell Core (with support for PS5) +- ✅ Docker support, including images for ARM/Raspberry Pi +- ✅ Azure Functions, AWS Lambda, and IIS support +- ✅ OpenAPI specification version 3.0.x and 3.1.0 +- ✅ OpenAPI documentation with Swagger, Redoc, RapidDoc, StopLight, OpenAPI-Explorer and RapiPdf +- ✅ Listen on a single or multiple IP(v4/v6) addresses/hostnames +- ✅ Cross-platform support for HTTP(S), WS(S), SSE, SMTP(S), and TCP(S) +- ✅ Host REST APIs, Web Pages, and Static Content (with caching) +- ✅ Support for custom error pages +- ✅ Request and Response compression using GZip/Deflate +- ✅ Multi-thread support for incoming requests +- ✅ Inbuilt template engine, with support for third-parties +- ✅ Async timers for short-running repeatable processes +- ✅ Async scheduled tasks using cron expressions for short/long-running processes +- ✅ Supports logging to CLI, Files, and custom logic for other services like LogStash +- ✅ Cross-state variable access across multiple runspaces +- ✅ Restart the server via file monitoring, or defined periods/times +- ✅ Ability to allow/deny requests from certain IP addresses and subnets +- ✅ Basic rate limiting for IP addresses and subnets +- ✅ Middleware and Sessions on web servers, with Flash message and CSRF support +- ✅ Authentication on requests, such as Basic, Windows and Azure AD +- ✅ Authorisation support on requests, using Roles, Groups, Scopes, etc. +- ✅ Enhanced authentication support, including Basic, Bearer (with JWT), Certificate, Digest, Form, OAuth2, and ApiKey (with JWT). +- ✅ Support for dynamically building Routes from Functions and Modules +- ✅ Generate/bind self-signed certificates +- ✅ Secret management support to load secrets from vaults +- ✅ Support for File Watchers +- ✅ In-memory caching, with optional support for external providers (such as Redis) +- ✅ (Windows) Open the hosted server as a desktop application +- ✅ FileBrowsing support +- ✅ Localization (i18n) in Arabic, German, Spanish, France, Italian, Japanese, Korean, Polish, Portuguese,Dutch and Chinese ## 📦 Install diff --git a/docs/Tutorials/Authentication/Methods/ApiKey.md b/docs/Tutorials/Authentication/Methods/ApiKey.md index 2167867cf..6d754daa0 100644 --- a/docs/Tutorials/Authentication/Methods/ApiKey.md +++ b/docs/Tutorials/Authentication/Methods/ApiKey.md @@ -26,13 +26,13 @@ Start-PodeServer { } ``` -By default, Pode will look for an `X-API-KEY` header in the request. You can change this to Cookie or Query by using the `-Location` parameter. To change the name of what Pode looks for, you can use `-LocationName`. +By default, Pode will look for an `X-API-KEY` header in the request. You can change this to Cookie or Query by using the `-ApiKeyLocation` parameter. To change the name of what Pode looks for, you can use `-LocationName`. For example, to look for an `appId` query value: ```powershell Start-PodeServer { - New-PodeAuthScheme -ApiKey -Location Query -LocationName 'appId' | Add-PodeAuth -Name 'Authenticate' -Sessionless -ScriptBlock { + New-PodeAuthScheme -ApiKey -ApiKeyLocation Query -LocationName 'appId' | Add-PodeAuth -Name 'Authenticate' -Sessionless -ScriptBlock { param($key) # check if the key is valid, and get user diff --git a/docs/Tutorials/Authentication/Methods/Bearer.md b/docs/Tutorials/Authentication/Methods/Bearer.md index 77f92abbe..be388f8ae 100644 --- a/docs/Tutorials/Authentication/Methods/Bearer.md +++ b/docs/Tutorials/Authentication/Methods/Bearer.md @@ -6,13 +6,30 @@ Bearer authentication lets you authenticate a user based on a token, with option Authorization: Bearer ``` +!!! note + **`New-PodeAuthScheme -Bearer` is deprecated.** Please use **`New-PodeAuthBearerScheme`**. + ## Setup -To start using Bearer authentication in Pode you can use `New-PodeAuthScheme -Bearer`, and then pipe the returned object into [`Add-PodeAuth`](../../../../Functions/Authentication/Add-PodeAuth). The parameter supplied to the [`Add-PodeAuth`](../../../../Functions/Authentication/Add-PodeAuth) function's ScriptBlock is the `$token` from the Authorization token: +To start using Bearer authentication in Pode, call **`New-PodeAuthBearerScheme`**, and then pipe the returned object into [`Add-PodeAuth`](../../../../Functions/Authentication/Add-PodeAuth). The parameter supplied to the [`Add-PodeAuth`](../../../../Functions/Authentication/Add-PodeAuth) function's **ScriptBlock** is the `$token` from the Authorization header. + +```powershell +Start-PodeServer { + New-PodeAuthBearerScheme | Add-PodeAuth -Name 'Authenticate' -Sessionless -ScriptBlock { + param($token) + + # check if the token is valid, and get user + + return @{ User = $user } + } +} +``` + +By default, Pode will look for a token in the **`Authorization`** header, verifying that it starts with the **`Bearer`** tag. You can customize this tag via **`-HeaderTag`**. You can also change the token extraction location to the **query string** using **`-Location Query`**. For the **`-Location query`** the standard tag is **`access_token`**: ```powershell Start-PodeServer { - New-PodeAuthScheme -Bearer | Add-PodeAuth -Name 'Authenticate' -Sessionless -ScriptBlock { + New-PodeAuthBearerScheme -Location Query | Add-PodeAuth -Name 'Authenticate' -Sessionless -ScriptBlock { param($token) # check if the token is valid, and get user @@ -22,13 +39,129 @@ Start-PodeServer { } ``` -By default, Pode will check if the request's header contains an `Authorization` key, and whether the value of that key starts with `Bearer` tag. The `New-PodeAuthScheme -Bearer` function can be supplied parameters to customise the tag using `-HeaderTag`. +**Note:** Per [RFC 6750](https://datatracker.ietf.org/doc/html/rfc6750), using the Authorization header is recommended for sending bearer tokens. Query parameters should only be used when headers are not feasible, as query strings may be logged in URLs, potentially exposing sensitive information. + +## JWT Support + +`New-PodeAuthBearerScheme` supports **JWT authentication** with various security levels and algorithms. Set **`-AsJWT`** to enable JWT validation. Depending on the chosen algorithm, you can specify: + +- **HMAC**-based secret keys (`-Secret`) +- **Certificate**-based parameters (`-Certificate`, `-CertificateThumbprint`, `-CertificateName`, `-X509Certificate`, `-SelfSigned`) +- The **RSA padding scheme** (`-RsaPaddingScheme`) +- The **JWT verification mode** (`-JwtVerificationMode`) + +### JwtVerificationMode + +Defines how aggressively JWT claims should be checked: + +- **Strict**: Requires all standard claims: + - `exp` (Expiration Time) + - `nbf` (Not Before) + - `iat` (Issued At) + - `iss` (Issuer) + - `aud` (Audience) + - `jti` (JWT ID) + +- **Moderate**: Allows missing `iss` (Issuer) and `aud` (Audience) but still checks expiration (`exp`). +- **Lenient**: Ignores missing `iss` and `aud`, only verifies expiration (`exp`), not-before (`nbf`), and issued-at (`iat`). + +### HMAC Example + +Here’s an example using **HMAC** (HS256) JWT validation: + +```powershell +Start-PodeServer { + New-PodeAuthBearerScheme ` + -AsJWT ` + -Algorithm 'HS256' ` + -Secret (ConvertTo-SecureString "MySecretKey" -AsPlainText -Force) ` + -JwtVerificationMode 'Strict' | + Add-PodeAuth -Name 'Authenticate' -Sessionless -ScriptBlock { + param($token) + + # validate and decode JWT, then extract user details + + return @{ User = $user } + } +} +``` + +### Certificate-Based Example + +For **RSA/ECDSA** JWT validation, you can specify a **certificate** or **thumbprint** instead of a secret key. Pode will infer the appropriate signing algorithms (e.g., RS256, ES256) from the certificate. For instance, using a local **PFX** certificate file: + +```powershell +Start-PodeServer { + New-PodeAuthBearerScheme ` + -AsJWT ` + -Algorithm 'RS256' ` + -Certificate "C:\path\to\cert.pfx" ` + -CertificatePassword (ConvertTo-SecureString "CertPass" -AsPlainText -Force) ` + -JwtVerificationMode 'Moderate' | + Add-PodeAuth -Name 'Authenticate' -Sessionless -ScriptBlock { + param($token) + + # validate JWT and extract user + + return @{ User = $user } + } +} +``` + +### Self-Signed Certificate Example + +For testing purposes or internal deployments, you can use the **`-SelfSigned`** parameter, which automatically generates an **ephemeral self-signed ECDSA certificate** (ES384) for JWT signing. This avoids the need to manually create and manage certificate files. + +#### Example: + +```powershell +Start-PodeServer { + New-PodeAuthBearerScheme ` + -AsJWT ` + -SelfSigned | + Add-PodeAuth -Name 'Authenticate' -Sessionless -ScriptBlock { + param($token) + + # validate JWT and extract user + + return @{ User = $user } + } +} +``` + +This is equivalent to manually generating a self-signed ECDSA certificate and passing it via `-X509Certificate`: + +```powershell +Start-PodeServer { + $x509Certificate = New-PodeSelfSignedCertificate ` + -CommonName 'JWT Signing Certificate' ` + -KeyType ECDSA ` + -KeyLength 384 ` + -CertificatePurpose CodeSigning ` + -Ephemeral + + New-PodeAuthBearerScheme ` + -AsJWT ` + -X509Certificate $x509Certificate | + Add-PodeAuth -Name 'Authenticate' -Sessionless -ScriptBlock { + param($token) + + # validate JWT and extract user + + return @{ User = $user } + } +} +``` + +Using `-SelfSigned` simplifies setup by automatically handling certificate creation and disposal, making it a convenient choice for local development and testing scenarios. + +## Scope Validation -You can also optionally return a `Scope` property alongside the `User`. If you specify any scopes with [`New-PodeAuthScheme`](../../../../Functions/Authentication/New-PodeAuthScheme) then it will be validated in the Bearer's post validator - a 403 will be returned if the scope is invalid. +You can optionally include `-Scope` when creating the scheme. Pode will validate any returned `Scope` from your auth **ScriptBlock** against the scheme’s required scopes. If the scope is invalid, Pode will return 403 (Forbidden). ```powershell Start-PodeServer { - New-PodeAuthScheme -Bearer -Scope 'write' | Add-PodeAuth -Name 'Authenticate' -Sessionless -ScriptBlock { + New-PodeAuthBearerScheme -Scope 'write' | Add-PodeAuth -Name 'Authenticate' -Sessionless -ScriptBlock { param($token) # check if the token is valid, and get user @@ -38,11 +171,13 @@ Start-PodeServer { } ``` + + ## Middleware -Once configured you can start using Bearer authentication to validate incoming requests. You can either configure the validation to happen on every Route as global Middleware, or as custom Route Middleware. +Once configured, you can instruct Pode to validate every request with Bearer authentication by using **Global Middleware**, or you can require it on individual Routes. -The following will use Bearer authentication to validate every request on every Route: +**Global Middleware Example** – Validate **every** incoming request: ```powershell Start-PodeServer { @@ -50,7 +185,7 @@ Start-PodeServer { } ``` -Whereas the following example will use Bearer authentication to only validate requests on specific a Route: +**Route-Specific Example** – Validate only on a certain Route: ```powershell Start-PodeServer { @@ -60,46 +195,42 @@ Start-PodeServer { } ``` -## JWT - -You can supply a JWT using Bearer authentication, for more details [see here](../JWT). - ## Full Example -The following full example of Bearer authentication will setup and configure authentication, validate the token, and then validate on a specific Route: +Below is a complete example demonstrating Bearer authentication with JWT. It configures a server, sets up JWT validation with a shared secret, and validates requests on one route (`/cpu`) while leaving another (`/memory`) open: ```powershell Start-PodeServer { Add-PodeEndpoint -Address * -Port 8080 -Protocol Http - # setup bearer authentication to validate a user - New-PodeAuthScheme -Bearer | Add-PodeAuth -Name 'Authenticate' -Sessionless -ScriptBlock { - param($token) - - # here you'd check a real storage, this is just for example - if ($token -eq 'test-token') { - return @{ - User = @{ - 'ID' ='M0R7Y302' - 'Name' = 'Morty' - 'Type' = 'Human' + # Setup Bearer authentication to validate a user via JWT + New-PodeAuthBearerScheme -AsJWT -Algorithm 'HS256' -Secret (ConvertTo-SecureString "MySecretKey" -AsPlainText -Force) -JwtVerificationMode 'Lenient' | + Add-PodeAuth -Name 'Authenticate' -Sessionless -ScriptBlock { + param($token) + + # Example: in real usage, you would decode/verify the JWT fully + if ($token -eq 'test-token') { + return @{ + User = @{ + 'ID' = 'M0R7Y302' + 'Name' = 'Morty' + 'Type' = 'Human' + } + # Scope = 'read' } - # Scope = 'read' } - } - # authentication failed - return $null - } + # authentication failed + return $null + } - # check the request on this route against the authentication + # Validate against the authentication on this route Add-PodeRoute -Method Get -Path '/cpu' -Authentication 'Authenticate' -ScriptBlock { Write-PodeJsonResponse -Value @{ 'cpu' = 82 } } - # this route will not be validated against the authentication + # Open route, no auth required Add-PodeRoute -Method Get -Path '/memory' -ScriptBlock { Write-PodeJsonResponse -Value @{ 'memory' = 14 } } } -``` diff --git a/docs/Tutorials/Authentication/Methods/Digest.md b/docs/Tutorials/Authentication/Methods/Digest.md index 95e1e9570..c9cfca84e 100644 --- a/docs/Tutorials/Authentication/Methods/Digest.md +++ b/docs/Tutorials/Authentication/Methods/Digest.md @@ -1,14 +1,16 @@ # Digest -Digest authentication lets you authenticate a user without actually sending the password to the server. Instead the a request is made to the server, and a challenge issued back for credentials. The authentication is then done by comparing hashes generated by the client and server using the user's password as a secret key. +Digest authentication allows secure user authentication without sending the password to the server. Instead, the client receives a challenge from the server and responds with a hash-based authentication response. The server then verifies the hash using the stored password as a secret key. + +**Pode's Digest Authentication is compliant with [RFC 7616](https://datatracker.ietf.org/doc/html/rfc7616)**, ensuring compatibility with standard authentication mechanisms. ## Setup -To setup and start using Digest authentication in Pode you use the `New-PodeAuthScheme -Digest` function, and then pipe this into the [`Add-PodeAuth`](../../../../Functions/Authentication/Add-PodeAuth) function. The parameters supplied to the [`Add-PodeAuth`](../../../../Functions/Authentication/Add-PodeAuth) function's ScriptBlock are the `$username`, and a HashTable containing the parameters from the Authorization header: +To configure Digest authentication in Pode, use the `New-PodeAuthScheme -Digest` function and pass it to [`Add-PodeAuth`](../../../../Functions/Authentication/Add-PodeAuth). The parameters supplied to the `Add-PodeAuth` function's ScriptBlock include the `$username` and a hashtable containing the authentication parameters extracted from the `Authorization` header: ```powershell Start-PodeServer { - New-PodeAuthScheme -Digest | Add-PodeAuth -Name 'Authenticate' -Sessionless -ScriptBlock { + New-PodeAuthScheme -Digest -Algorithm "SHA-256" -QualityOfProtection "auth-int" | Add-PodeAuth -Name 'Authenticate' -Sessionless -ScriptBlock { param($username, $params) # check if the user is valid @@ -18,28 +20,70 @@ Start-PodeServer { } ``` -Unlike other forms of authentication where you only need return the User on success. Digest requires you to also return the Password of the user as a separate property. This password is what is used as the secret key to generate the client's response hash, and allows the server to re-generate the hash for validation. (Not returning the password will result in an HTTP 401 challenge response). +Unlike other authentication methods, where only a user object is returned on success, Digest authentication **requires returning the password** (or hash) as a separate property. The password acts as the secret key to regenerate the client’s hash response for verification. Not returning the password results in an HTTP `401 Unauthorized` challenge response. + +### RFC 7616 Compliance + +Pode’s Digest authentication implementation adheres to **RFC 7616**, ensuring: + +- Use of **nonce-based challenge-response authentication** +- Support for **multiple hashing algorithms** beyond MD5 +- Support for **Quality of Protection (QoP)**, including `auth` and `auth-int` +- Correct formatting of **WWW-Authenticate** headers on authentication failure + +!!! note + SHA-384 is **not** part of RFC 7616 but has been added for consistency with other modern cryptographic algorithms and to provide additional security options. + +### Supported Algorithms + +Pode now supports multiple algorithms for Digest authentication. The `-Algorithm` parameter allows selecting one or more of the following: + +- `MD5` +- `SHA-1` +- `SHA-256` +- `SHA-384` +- `SHA-512` +- `SHA-512/256` + +Pode automatically includes all supported algorithms in the `WWW-Authenticate` challenge header, allowing clients to select the strongest available option. + +### Quality of Protection (QoP) + +The `-QualityOfProtection` parameter (`-qop`) allows choosing between: + +- `"auth"` (authentication only) +- `"auth-int"` (authentication with message integrity protection) + +If `auth-int` is used, the client includes a hash of the request body in the authentication response, ensuring the request content has not been altered. -By default, Pode will check if the Request's header contains an `Authorization` key, and whether the value of that key starts with `Digest` tag. The `New-PodeAuthScheme -Digest` function can be supplied parameters to customise the tag using `-HeaderTag`. Pode will also gather the rest of the parameters in the header such as the Nonce, NonceCount, etc. An HTTP 401 challenge will be sent back if the Authorization header is invalid. +## Handling Authentication Requests -The HashTable of parameters sent to the [`Add-PodeAuth`](../../../../Functions/Authentication/Add-PodeAuth) function's ScriptBlock are the following: +By default, Pode checks if the request contains an `Authorization` header with the `Digest` scheme. The `New-PodeAuthScheme -Digest` function can be customized using the `-HeaderTag` parameter to modify the tag used in the request header. Pode also extracts all required parameters from the header, including the nonce, nonce count, and QoP options. -| Parameter | Description | -| --------- | ----------- | -| cnonce | A nonce value generated by the client | -| nc | The count of time the client has used the server nonce | -| nonce | A nonce value generated by the server | -| qop | Fixed to 'auth' | -| realm | The realm description from the server's HTTP 401 challenge | -| response | The generated hash value of all these parameters from the client | -| uri | The URI path that needs authentication | -| username | The username of the user that needs authenticating | +If the `Authorization` header is missing or invalid, Pode returns an HTTP `401 Unauthorized` response with a `WWW-Authenticate` challenge. + +### Digest Authentication Parameters + +The hashtable of parameters passed to the `Add-PodeAuth` function’s ScriptBlock includes the following: + +| Parameter | Description | +|------------|--------------| +| `cnonce` | A nonce value generated by the client. | +| `nc` | The count of times the client has used the server nonce. | +| `nonce` | A nonce value generated by the server. | +| `qop` | The quality of protection requested (`auth` or `auth-int`). | +| `realm` | The authentication realm from the server's challenge. | +| `response` | The hash generated by the client for authentication. | +| `uri` | The URI path that requires authentication. | +| `username` | The username provided for authentication. | ## Middleware -Once configured you can start using Digest authentication to validate incoming Requests. You can either configure the validation to happen on every Route as global Middleware, or as custom Route Middleware. +Digest authentication can be applied globally to all requests using `Add-PodeAuthMiddleware` or to specific routes via the `-Authentication` parameter. -The following will use Digest authentication to validate every request on every Route: +### Global Middleware + +To apply Digest authentication globally to all routes: ```powershell Start-PodeServer { @@ -47,7 +91,9 @@ Start-PodeServer { } ``` -Whereas the following example will use Digest authentication to only validate requests on specific a Route: +### Per-Route Middleware + +To enforce Digest authentication only on specific routes: ```powershell Start-PodeServer { @@ -59,40 +105,116 @@ Start-PodeServer { ## Full Example -The following full example of Digest authentication will setup and configure authentication, validate that a user's username is valid, and then validate on a specific Route: +The following example sets up Digest authentication with SHA-256 and `auth-int`, validates a user, and applies authentication to a specific route: ```powershell Start-PodeServer { Add-PodeEndpoint -Address * -Port 8080 -Protocol Http - # setup digest authentication to validate a user - New-PodeAuthScheme -Digest | Add-PodeAuth -Name 'Authenticate' -Sessionless -ScriptBlock { + # Setup Digest authentication with SHA-256 and auth-int + New-PodeAuthScheme -Digest -Algorithm "SHA-256" -QualityOfProtection "auth-int" | Add-PodeAuth -Name 'Authenticate' -Sessionless -ScriptBlock { param($username, $params) - # here you'd check a real user storage, this is just for example + # Example user validation if ($username -eq 'morty') { return @{ User = @{ - 'ID' ='M0R7Y302' - 'Name' = 'Morty'; - 'Type' = 'Human'; + 'ID' = 'M0R7Y302' + 'Name' = 'Morty' + 'Type' = 'Human' } Password = 'pickle' } } - # authentication failed + # Authentication failed return $null } - # check the request on this route against the authentication + # Protect the /cpu route with Digest authentication Add-PodeRoute -Method Get -Path '/cpu' -Authentication 'Authenticate' -ScriptBlock { Write-PodeJsonResponse -Value @{ 'cpu' = 82 } } - # this route will not be validated against the authentication + # The /memory route is accessible without authentication Add-PodeRoute -Method Get -Path '/memory' -ScriptBlock { Write-PodeJsonResponse -Value @{ 'memory' = 14 } } } ``` + +### **Windows-Specific Limitations and the Pode Client Module** + +Windows' built-in Digest authentication has several **critical limitations** that restrict its compatibility with modern security practices: + +- **Limited to MD5:** Windows does not support stronger hashing algorithms like SHA-256 or SHA-512. +- **No Support for `auth-int`:** Integrity protection (`auth-int`) is not available, making it less secure. +- **Fails with Multiple Algorithms:** If the `WWW-Authenticate` header lists multiple algorithms, Windows' built-in implementation fails to negotiate properly. +- **Lack of Algorithm Negotiation:** Windows cannot automatically select the strongest supported algorithm from a list. + +### **Overcoming Windows Limitations with the Pode Client Module** + +To bypass these **Windows client limitations**, Pode provides a custom **client module** that supports full RFC 7616-compliant Digest authentication. This module allows PowerShell scripts to authenticate using modern algorithms, multiple QoP modes, and cross-platform compatibility. + +The **client module** is available under: + +```powershell +Import-Module ./examples/Authentication/Modules/Invoke-Digest.psm1 +``` + +By using this module, you can perform **secure Digest authentication** in PowerShell, even on Windows, without being restricted to **MD5-only authentication**. + +The module includes the following functions: + +### **Invoke-WebRequestDigest** + +A replacement for `Invoke-WebRequest` that supports Digest authentication. + +#### **Example Usage** + +```powershell +Import-Module './examples/Authentication/Modules/Invoke-Digest.psm1' + +# Define the URI and credentials +$uri = 'http://localhost:8081/users' +$username = 'morty' +$password = 'pickle' + +# Convert the password to a SecureString and create a credential object +$securePassword = ConvertTo-SecureString $password -AsPlainText -Force +$credential = [System.Management.Automation.PSCredential]::new($username, $securePassword) + +# Make a GET request using Digest authentication +$response = Invoke-WebRequestDigest -Uri $uri -Method 'GET' -Credential $credential + +# Display response headers and content +$response.Headers | Format-List +Write-Output $response.Content +``` + +--- + +### **Invoke-RestMethodDigest** + +A replacement for `Invoke-RestMethod` that supports Digest authentication. + +#### **Example Usage** + +```powershell +Import-Module './examples/Authentication/Modules/Invoke-Digest.psm1' + +# Define the URI and credentials +$uri = 'http://localhost:8081/users' +$username = 'morty' +$password = 'pickle' + +# Convert the password to a SecureString and create a credential object +$securePassword = ConvertTo-SecureString $password -AsPlainText -Force +$credential = [System.Management.Automation.PSCredential]::new($username, $securePassword) + +# Make a GET request and automatically parse JSON response +$response = Invoke-RestMethodDigest -Uri $uri -Method 'GET' -Credential $credential + +# Output the parsed response +$response +``` \ No newline at end of file diff --git a/docs/Tutorials/Authentication/Methods/JWT.md b/docs/Tutorials/Authentication/Methods/JWT.md index 9a24416ff..3e2453921 100644 --- a/docs/Tutorials/Authentication/Methods/JWT.md +++ b/docs/Tutorials/Authentication/Methods/JWT.md @@ -1,112 +1,299 @@ -# JWT +# Create a JWT -Pode has inbuilt JWT parsing for either [Bearer](../Bearer) or [API Key](../ApiKey) authentications. Pode will attempt to validate and parse the token/key as a JWT, and if successful the JWT's payload will be passed as the parameter to [`Add-PodeAuth`](../../../../Functions/Authentication/Add-PodeAuth), instead of the token/key. +Pode provides a [`ConvertTo-PodeJwt`](../../../../Functions/Authentication/ConvertTo-PodeJwt) command that builds and signs a JWT for you. You can provide: -For more information on JWTs, see the [official website](https://jwt.io). +- **`-Header`**: A hashtable defining fields like `alg`, `typ`, etc. +- **`-Payload`**: A hashtable for JWT claims (e.g., `sub`, `exp`, `nbf`, and other custom claims). +- **`-Secret`**/**`-Certificate`**/**`-CertificateThumbprint`**, etc.: If you want to sign the JWT (for HS*, RS*, ES*, PS* algorithms). +- **`-IgnoreSignature`**: If you want a token with no signature (alg = none). +- **`-Authentication`**: To reference an existing named authentication scheme, automatically pulling its parameters (algorithm, secret, certificate, etc.) so the generated JWT is recognized by that scheme. -## Setup +## Customizing the Header/Payload -To start using JWT authentication, you can supply the `-AsJWT` switch with either the `-Bearer` or `-ApiKey` switch on [`New-PodeAuthScheme`](../../../../Functions/Authentication/New-PodeAuthScheme). You can also supply an optional `-Secret` that the JWT signature uses so Pode can validate the JWT: +When generating a JWT using **`ConvertTo-PodeJwt`**, you can specify parameters that either: + +1. **Manually** define the header/payload using `-Header` and `-Payload`, or +2. **Automatically** set standard claims via shortcut parameters like `-Expiration`, `-Issuer`, `-Audience`, etc. + +You can also combine these approaches—Pode merges everything into the final token unless you use **`-NoStandardClaims`** to disable automatic claims. + +Below are the **primary parameters** you can pass to **`ConvertTo-PodeJwt`**: + +### Header and Payload + +- **`-Header`**: A hashtable for JWT header fields (e.g., `alg`, `typ`). +- **`-Payload`**: A hashtable for arbitrary/custom claims (e.g., `role`, `scope`, etc.). +- **`-NoStandardClaims`**: If specified, **no** standard claims are auto-generated (e.g., no `exp`, `nbf`, `iat`, etc.). This is useful if you want full control over claims in `-Payload`. + +#### Standard Claims Parameters + +These automatically populate or override common JWT claims: + +- **`-Expiration`** (`int`, default 3600) + - Sets the `exp` (expiration time) to the current time + `Expiration` (in seconds). + - For example, **3600** means `exp` = now + 1 hour. + +- **`-NotBefore`** (`int`, default 0) + - Sets the `nbf` (not-before) to current time + `NotBefore` (in seconds). + - **0** = immediate validity; **60** = valid 1 minute from now, etc. + +- **`-IssuedAt`** (`int`, default 0) + - Sets the `iat` (issued-at) time. + - **0** means “use current time.” Any other integer is added to the current time as seconds. + +- **`-Issuer`** (`string`) + - Sets the `iss` (issuer) claim, e.g. `"auth.example.com"`. + +- **`-Subject`** (`string`) + - Sets the `sub` (subject) claim, e.g. `"user123"`. + +- **`-Audience`** (`string`) + - Sets the `aud` (audience) claim, e.g. `"myapi.example.com"`. + +- **`-JwtId`** (`string`) + - Sets the `jti` (JWT ID) claim, a unique identifier for the token. + +If you **also** supply the same claims in your `-Payload` hashtable, Pode typically defers to your explicit claim unless **`-NoStandardClaims`** is omitted, in which case these parameters can overwrite the payload-based claims. + +--- + +### Example Usage + +Below is an example that **automatically** sets standard claims for expiration (1 hour from now), not-before (starts immediately), and an issuer, while also providing a custom header/payload: ```powershell -# jwt with no signature: -New-PodeAuthScheme -Bearer -AsJWT | Add-PodeAuth -Name 'Example' -Sessionless -ScriptBlock { - param($payload) +$header = @{ + alg = 'HS256' + typ = 'JWT' } -# jwt with signature, signed with secret "abc": -New-PodeAuthScheme -ApiKey -AsJWT -Secret 'abc' | Add-PodeAuth -Name 'Example' -Sessionless -ScriptBlock { - param($payload) +$payload = @{ + role = 'admin' + customClaim = 'someValue' } + +$jwt = ConvertTo-PodeJwt ` + -Header $header ` + -Payload $payload ` + -Secret 'SuperSecretKey' ` + -Expiration 3600 ` + -NotBefore 0 ` + -Issuer 'auth.example.com' ` + -Subject 'user123' ` + -Audience 'myapi.example.com' ` + -JwtId 'unique-token-id' + +Write-PodeJsonResponse -Value @{ token = $jwt } ``` -The `$payload` will be a PSCustomObject of the converted JSON payload. For example, sending the following unsigned JWT in a request: +This produces a JWT that includes: -```plain -eyJhbGciOiJub25lIn0.eyJ1c2VybmFtZSI6Im1vcnR5Iiwic3ViIjoiMTIzIn0. -``` +- A header with `alg = HS256`, `typ = JWT`. +- Standard claims: `exp`, `nbf`, `iat`, `iss`, `sub`, `aud`, `jti`. +- Custom claims: `role`, `customClaim`. -would produce a payload of: +If you **don’t** want Pode to generate any standard claims at all (perhaps you want to define everything in `-Payload` yourself), include **`-NoStandardClaims`**: -```plain -sub: 123 -username: morty +```powershell +$jwt = ConvertTo-PodeJwt -NoStandardClaims -Payload @{ sub='user123'; customKey='abc' } -Secret 'SuperSecretKey' ``` -### Algorithms +No `exp`, `nbf`, or `iat` will be automatically added. -Pode supports the following algorithms for JWT signatures: +Similarly, if you have a named scheme: -* None -* HS256 -* HS384 -* HS512 +```powershell +New-PodeAuthBearerScheme -AsJWT -Algorithm 'RS256' -Certificate 'C:\cert.pfx' -CertificatePassword (ConvertTo-SecureString "CertPass" -AsPlainText -Force) | + Add-PodeAuth -Name 'ExampleApiKeyCert' -For `none`, Pode expects there to be no signature with the JWT. For other algorithms, a `-Secret` is required, and a signature must be supplied with the JWT in requests. +Add-PodeRoute -Method Post -Path '/login' -ScriptBlock { + $jwt = ConvertTo-PodeJwt ` + -Authentication 'ExampleApiKeyCert' ` + -Issuer 'auth.example.com' ` + -Expiration 3600 ` + -Subject 'user123' -### Payload + Write-PodeJsonResponse -Value @{ token = $jwt } +} +``` -If the payload of the JWT contains a expiry (`exp`) or a not before (`nbf`) timestamp, Pode will validate it and return a 400 if the JWT is expired/not started. +Here, Pode automatically applies the RS256 certificate from **`ExampleApiKeyCert`** and merges your standard-claims parameters, producing a token recognized by that same scheme upon verification. -## Usage +## Using `-Authentication` -To send the JWT in a request, the JWT should be sent in place of where the usual bearer token/API key would have been. For example, for bearer it would be in the Authorization header: +If you have already set up an authentication scheme, for instance: -```plain -Authorization: Bearer +```powershell +New-PodeAuthBearerScheme -AsJWT -Algorithm 'RS256' -Certificate 'C:\path\to\cert.pfx' -CertificatePassword (ConvertTo-SecureString "CertPass" -AsPlainText -Force) | + Add-PodeAuth -Name 'ExampleApiKeyCert' ``` -and for API keys, it would be in the location defined (header, cookie, or query string). For example, in the X-API-KEY header: +then you can **reuse** this scheme’s configuration when creating a token by calling: -```plain -X-API-KEY: -``` +```powershell +$jwt = ConvertTo-PodeJwt -Authentication 'ExampleApiKeyCert' -## Create JWT +# e.g., return the new JWT to a client +Write-PodeJsonResponse -StatusCode 200 -Value @{ jwt_token = $jwt } +``` -Pode has a simple [`ConvertTo-PodeJwt`](../../../../Functions/Authentication/ConvertTo-PodeJwt) that will build a JWT for you. It accepts a hashtable for `-Header` and `-Payload`, as well as an optional `-Secret`. +Pode automatically looks up the **`ExampleApiKeyCert`** auth scheme, retrieves its signing algorithm and key/certificate, and uses those to generate a valid JWT. This ensures that the JWT you create can later be **decoded and verified** by the same auth scheme without having to re-specify all parameters (secret, certificate, etc.). -The function will run some simple validation, and them build the JWT for you. +### Example -For example: +Below is a short example of how you might implement a **login** route that returns a signed JWT: ```powershell -$header = @{ - alg = 'hs256' - typ = 'JWT' +Add-PodeRoute -Method Post -Path '/user/login' -ScriptBlock { + param() + + # In a real scenario, you'd validate the incoming credentials from $WebEvent.data + $username = $WebEvent.Data['username'] + $password = $WebEvent.Data['password'] + + # If valid, generate a JWT that matches the 'ExampleApiKeyCert' scheme + $jwt = ConvertTo-PodeJwt -Authentication 'ExampleApiKeyCert' + + Write-PodeJsonResponse -StatusCode 200 -Value @{ jwt_token = $jwt } } +``` -$payload = @{ - sub = '123' - name = 'John Doe' - exp = ([System.DateTimeOffset]::Now.AddDays(1).ToUnixTimeSeconds()) +In this example, the **`-Authentication`** parameter ensures Pode uses the RS256 certificate-based configuration already defined by the `ExampleApiKeyCert` auth scheme, producing a token that is verifiable by that same scheme on future requests. + + +Below is an **updated JWT Lifecycle guide** for Pode, clarifying that **Pode automatically validates the token** when you attach `-Authentication` to a route, and that **`ConvertFrom-PodeJwt`** is generally used for **inspecting** or **debugging** token contents. + + +## Managing the JWT Lifecycle in Pode + +In many scenarios, you need more than just generating JWTs—you also need endpoints or logic for **renewing** and **inspecting** tokens. Pode’s built-in commands and authentication features enable these patterns quickly: + +1. **Creating a JWT**: Use [`ConvertTo-PodeJwt`](https://github.com/Badgerati/Pode/blob/develop/Functions/Authentication/ConvertTo-PodeJwt.ps1) to build and sign a JWT. +2. **Automatic Validation**: Rely on Pode’s bearer auth if a route uses `-Authentication 'YourBearerScheme'`. +3. **Decoding/Inspecting a JWT**: Use `ConvertFrom-PodeJwt` if you want to explicitly decode the JWT for debugging or extracting claims. +4. **Renewing/Extending a JWT**: Use `Update-PodeJwt` to reissue a token with a new expiration. + +## 1. Creating a JWT + +See the [“Create a JWT” guide](#create-a-jwt) for details on using `ConvertTo-PodeJwt`. You can: + +- Define a scheme in Pode (e.g., `Bearer_JWT_ES512`) that holds your algorithm and certificates/secrets. +- Generate tokens by referencing `-Authentication 'Bearer_JWT_ES512'`. +- Optionally set custom claims, expiration, issuer, etc. + +This creation step often happens inside a **login** route, as shown in the example below: + +```powershell +function Test-User { + param($username, $password) + if ($username -eq 'morty' -and $password -eq 'pickle') { + return @{ + Id = 'M0R7Y302' + Username = 'morty.smith' + Name = 'Morty Smith' + Groups = 'Domain Users' + } + } + throw 'Invalid credentials' } -ConvertTo-PodeJwt -Header $header -Payload $payload -Secret 'abc' +Add-PodeRoute -Method Post -Path '/auth/login' -ScriptBlock { + try { + $username = $WebEvent.Data.username + $password = $WebEvent.Data.password + $user = Test-User $username $password # Validate credentials in some real store + + $payload = @{ + sub = $user.Id + name = $user.Name + # ... more custom claims ... + } + + # Generate JWT recognized by the scheme 'Bearer_JWT_ES512' + $jwt = ConvertTo-PodeJwt -Payload $payload -Authentication 'Bearer_JWT_ES512' -Expiration 600 + + Write-PodeJsonResponse -StatusCode 200 -Value @{ + success = $true + user = $user + jwt = $jwt + } + } + catch { + Write-PodeJsonResponse -StatusCode 401 -Value @{ error = 'Invalid credentials' } + } +} ``` -This return the following JWT: +## 2. Automatic Validation + +Once you have a named bearer scheme (e.g., `Bearer_JWT_ES512`), **any** route that includes `-Authentication 'Bearer_JWT_ES512'` is automatically protected. Pode will: + +- Extract the JWT from the HTTP `Authorization` header (or another location if specified). +- Decode and verify the signature based on the scheme’s configuration. +- Reject the request if invalid; otherwise, set `$WebEvent.Auth.User` with any relevant user/claims data. -```plain -eyJ0eXAiOiJKV1QiLCJhbGciOiJoczI1NiJ9.eyJleHAiOjE2MjI1NTMyMTQsIm5hbWUiOiJKb2huIERvZSIsInN1YiI6IjEyMyJ9.LP-O8OKwix91a-SZwVK35gEClLZQmsORbW0un2Z4RkY +```powershell +Add-PodeRoute -Method Get -Path '/secure' -Authentication 'Bearer_JWT_ES512' -ScriptBlock { + # If we get here, the token is valid + $user = $WebEvent.Auth.User + Write-PodeJsonResponse -Value @{ user = $user; message = 'Welcome!' } +} ``` -## Parse JWT +No need to manually call `ConvertFrom-PodeJwt`—Pode handles validation behind the scenes. -Pode has a [`ConvertFrom-PodeJwt`](../../../../Functions/Authentication/ConvertFrom-PodeJwt) that can be used to parse a valid JWT. Only the algorithms at the top of this page are supported for verifying the signature. You can skip signature verification by passing `-IgnoreSignature`. On success, the payload of the JWT is returned. +## 3. Decoding/Inspecting a JWT -For example, if the created JWT was supplied: +Sometimes you want to **inspect** a token or decode it for debugging. That’s where `ConvertFrom-PodeJwt` is handy. For example, you might have a route that **also** includes `-Authentication 'Bearer_JWT_ES512'` (so the user needs a valid token to get in), but within the route you call `ConvertFrom-PodeJwt` to see the raw contents or claims: ```powershell -ConvertFrom-PodeJwt -Token 'eyJ0eXAiOiJKV1QiLCJhbGciOiJoczI1NiJ9.eyJleHAiOjE2MjI1NTMyMTQsIm5hbWUiOiJKb2huIERvZSIsInN1YiI6IjEyMyJ9.LP-O8OKwix91a-SZwVK35gEClLZQmsORbW0un2Z4RkY' -Secret 'abc' +Add-PodeRoute -Method Post -Path '/auth/bearer/jwt/info' -Authentication 'Bearer_JWT_ES512' -ScriptBlock { + try { + # Although Pode already validated the token, we can decode it ourselves for debugging + $decoded = ConvertFrom-PodeJwt -Outputs 'Header,Payload,Signature' -HumanReadable + Write-PodeJsonResponse -Value $decoded + } + catch { + Write-PodeJsonResponse -StatusCode 401 -Value @{ error = 'Invalid JWT token supplied' } + } +} ``` -then the following would be returned: +This route returns the **header, payload, and signature** in JSON, with timestamps (like `exp`, `nbf`, `iat`) converted to human-readable dates. + +## 4. Renewing/Extending a JWT with `Update-PodeJwt` + +Use `Update-PodeJwt` to **extend** an existing token’s lifetime. Typically, you create a `/renew` endpoint: ```powershell -@{ - sub = '123' - name = 'John Doe' - exp = 1636657408 +Add-PodeRoute -Method Post -Path '/auth/bearer/jwt/renew' -Authentication 'Bearer_JWT_ES512' -ScriptBlock { + try { + # Reads the current valid JWT, reissues it with a fresh 'exp' claim + $newToken = Update-PodeJwt + Write-PodeJsonResponse -StatusCode 200 -Value @{ success = $true; jwt = $newToken } + } + catch { + Write-PodeJsonResponse -StatusCode 401 -Value @{ error = 'Invalid JWT token supplied' } + } } ``` + +Pode fetches the token from `$WebEvent`, checks the original scheme (here, `Bearer_JWT_ES512`), and re-signs with updated expiration. The rest of the claims stay the same. The client can then discard the old token and use the newly returned token moving forward. + +--- + +## Full Lifecycle Example + +**1.** **Login** (create token) +**2.** **Make Authenticated Requests** (Pode automatically validates) +**3.** **Renew** (use `Update-PodeJwt` if needed) +**4.** **Debug** (optionally decode token with `ConvertFrom-PodeJwt`) + +This covers a typical JWT flow in Pode: + +- The user logs in at `/auth/login`, gets a JWT. +- They pass that JWT in subsequent requests, which are auto-validated by `-Authentication 'Bearer_JWT_ES512'`. +- If the token is about to expire, they can call `/auth/bearer/jwt/renew` to get a fresh one. +- If you need to debug claims, you can build an endpoint that calls `ConvertFrom-PodeJwt` or look at `$WebEvent.Auth.User`. + +For more details, see the [Pode GitHub examples](https://github.com/Badgerati/Pode/tree/develop/examples/Authentication) or the relevant [`ConvertTo-PodeJwt`](https://github.com/Badgerati/Pode/blob/develop/Functions/Authentication/ConvertTo-PodeJwt.ps1) and [`Update-PodeJwt`](https://github.com/Badgerati/Pode/blob/develop/Functions/Authentication/Update-PodeJwt.ps1) source files. \ No newline at end of file diff --git a/docs/Tutorials/Basics.md b/docs/Tutorials/Basics.md index 137aa8b29..f887da2e5 100644 --- a/docs/Tutorials/Basics.md +++ b/docs/Tutorials/Basics.md @@ -1,4 +1,5 @@ # Basics + !!! Warning You can initiate only one server per PowerShell instance. To run multiple servers, start additional PowerShell, or pwsh, sessions. Each session can run its own server. This is fundamental to how Pode operates, so consider it when designing your scripts and infrastructure. @@ -59,6 +60,7 @@ When you call [`Start-PodeServer`](../../Functions/Core/Start-PodeServer) direct For example, the following is a file that contains the same scriptblock for the server at the top of this page. Following that are the two ways to run the server - the first is via another script, and the second is directly from the CLI: * File.ps1 + ```powershell { # attach to port 8080 for http @@ -70,12 +72,15 @@ For example, the following is a file that contains the same scriptblock for the ``` * Server.ps1 (start via script) + ```powershell Start-PodeServer -FilePath './File.ps1' ``` + then use `./Server.ps1` on the CLI. * CLI (start from CLI) + ```powershell PS> Start-PodeServer -FilePath './File.ps1' ``` diff --git a/docs/Tutorials/Certificates.md b/docs/Tutorials/Certificates.md index be3a9e606..2aff12dae 100644 --- a/docs/Tutorials/Certificates.md +++ b/docs/Tutorials/Certificates.md @@ -1,121 +1,313 @@ # Certificates -Pode has the ability to generate and bind self-signed certificates (for dev/testing), as well as the ability to bind existing certificates for HTTPS. +Pode has the ability to generate and bind self-signed certificates (for dev/testing), as well as the ability to bind existing certificates for HTTPS or JWT. -There are 8 ways to setup HTTPS on [`Add-PodeEndpoint`](../../Functions/Core/Add-PodeEndpoint): +## Setting Up HTTPS in Pode -1. Supplying just the `-Certificate`, which is the path to files such as a `.cer` or `.pem` file. -2. Supplying both the `-Certificate` and `-CertificatePassword`, which is the path to a `.pfx` file and its password. -3. Supplying both the `-Certificate` and `-CertificateKey`, which is the paths to certificate/key PEM file pairs. -4. Supplying all of `-Certificate`, `-CertificateKey`, and `-CertificatePassword`, which is the paths to certificate/key PEM file pairs and the password for an encrypted key. -5. Supplying a `-CertificateThumbprint` for a certificate installed at `Cert:\CurrentUser\My` on Windows. -6. Supplying a `-CertificateName` for a certificate installed at `Cert:\CurrentUser\My` on Windows. -7. Supplying `-X509Certificate` of type `X509Certificate`. -8. Supplying the `-SelfSigned` switch, to generate a quick self-signed `X509Certificate`. +Pode provides multiple ways to configure HTTPS on [`Add-PodeEndpoint`](../../Functions/Core/Add-PodeEndpoint): -Note: for 5. and 6. you can change the certificate store used by supplying `-CertificateStoreName` and/or `-CertificateStoreLocation`. +- **File-based certificates:** + - `-Certificate`: Path to a `.cer` or `.pem` file. + - `-Certificate` with `-CertificatePassword`: Path to a `.pfx` file and its password. + - `-Certificate` with `-CertificateKey`: Paths to a certificate/key PEM file pair. + - `-Certificate`, `-CertificateKey`, and `-CertificatePassword`: Paths to an encrypted PEM file pair and its password. -## Usage +- **Windows Certificate Store:** + - `-CertificateThumbprint`: Uses a certificate installed at `Cert:\CurrentUser\My`. + - `-CertificateName`: Uses a certificate installed at `Cert:\CurrentUser\My` by name. -### File +- **X.509 Certificates:** + - `-X509Certificate`: Provides a certificate object of type `X509Certificate2`. + - `-SelfSigned`: Generates a quick self-signed `X509Certificate` for development. -#### PFX +- **Custom Certificate Management:** + - Pode’s built-in functions allow better control over certificate creation, import, and export. -To bind a certificate PFX file, you use the `-Certificate` parameter, along with the `-CertificatePassword` parameter for the PFX certificate. The following example supplies the path to some `.pfx` to enable HTTPS support: +## Generating a Self-Signed Certificate + +Pode provides the **`New-PodeSelfSignedCertificate`** function for creating self-signed X.509 certificates for development and testing purposes. + +### Features of `New-PodeSelfSignedCertificate` + +- ✅ Creates a **self-signed certificate** for HTTPS, JWT, or other use cases. +- ✅ Supports **RSA** and **ECDSA** keys with configurable key sizes. +- ✅ Can include **multiple Subject Alternative Names (SANs)** (e.g., `localhost`, IP addresses). +- ✅ Allows setting **certificate purposes (ServerAuth, ClientAuth, etc.).** +- ✅ Provides **ephemeral certificates** (in-memory only, not stored on disk). +- ✅ Supports **exportable certificates** that can be saved for later use. + +### Usage Examples + +#### 1️⃣ Generate a Self-Signed Certificate for HTTPS ```powershell -Start-PodeServer { - Add-PodeEndpoint -Address * -Port 8090 -Protocol Https -Certificate './cert.pfx' -CertificatePassword 'Hunter2' -} +$cert = New-PodeSelfSignedCertificate -DnsName "example.com" -CertificatePurpose ServerAuth ``` -#### PEM +- Creates a **self-signed RSA certificate** for `example.com`. +- The certificate is valid for HTTPS (`ServerAuth`). -Pode has support for binding certificate/key PEM file pairs, on PowerShell 7+ this works out-of-the-box. However, for PowerShell 5/6 you are required to have OpenSSL installed. +#### 2️⃣ Generate a Self-Signed Certificate for Local Development -To bind a certificate/key PEM file pairs generated via LetsEncrypt or OpenSSL, you supply their paths to the `-Certificate` and `-CertificateKey` parameters. +```powershell +$cert = New-PodeSelfSignedCertificate -Loopback +``` + +- Automatically includes common loopback addresses: + - `127.0.0.1` + - `::1` + - `localhost` + - The machine’s hostname + +#### 3️⃣ Generate an ECDSA Certificate -For example, if you generate the certificate/key using the following: -```bash -openssl req -x509 -newkey rsa:2048 -keyout key.pem -out cert.pem -days 365 -nodes +```powershell +$cert = New-PodeSelfSignedCertificate -DnsName "test.local" -KeyType "ECDSA" -KeyLength 384 ``` -Then your endpoint would be created as: +- Creates a **self-signed ECDSA certificate** with a **384-bit** key. + +#### 4️⃣ Generate a Certificate That Exists Only in Memory (Ephemeral) + +```powershell +$cert = New-PodeSelfSignedCertificate -DnsName "temp.local" -Ephemeral +``` + +- The private key is **not stored on disk**, and the certificate only exists **in-memory**. + +#### 5️⃣ Generate an Exportable Certificate + +```powershell +$cert = New-PodeSelfSignedCertificate -DnsName "secureapp.local" -Exportable +``` + +- The certificate is **exportable** and can be saved as a `.pfx` or `.pem` file later. + +#### 6️⃣ Bind a Self-Signed Certificate to an HTTPS Endpoint + ```powershell Start-PodeServer { - Add-PodeEndpoint -Address * -Port 8090 -Protocol Https -Certificate './cert.pem' -CertificateKey './key.pem' + $cert = New-PodeSelfSignedCertificate -DnsName "example.com" -CertificatePurpose ServerAuth + Add-PodeEndpoint -Address * -Port 8443 -Protocol Https -X509Certificate $cert } ``` -However, if you generate the certificate/key and encrypt the key with a passphrase: -```bash -openssl req -x509 -newkey rsa:2048 -keyout key.pem -out cert.pem -days 365 +- Creates an HTTPS endpoint using a self-signed certificate. + +--- + +## Generating a Certificate Signing Request (CSR) + +To generate a Certificate Signing Request (CSR) along with a private key, use the **`New-PodeCertificateRequest`** function: + +```powershell +$csr = New-PodeCertificateRequest -DnsName "example.com" -CommonName "example.com" -KeyType "RSA" -KeyLength 2048 ``` -Then the endpoint is created as follows: +This will create a CSR file and a private key file in the current directory. You can specify additional parameters such as organization details and certificate purposes. + +### Using a CSR to Obtain a Certificate + +Once you have generated a CSR, you need to submit it to a **Certificate Authority (CA)** (such as Let's Encrypt, DigiCert, or a private CA) to receive a signed certificate. The process typically involves: + +1. Uploading or providing the `.csr` file to the CA. +2. Completing domain validation steps (if required). +3. Receiving the signed certificate (`.cer`, `.pem`, or `.pfx`) from the CA. +4. Importing the signed certificate into Pode for use. + +Example: Importing the signed certificate after receiving it from the CA: + ```powershell -Start-PodeServer { - Add-PodeEndpoint -Address * -Port 8090 -Protocol Https -Certificate './cert.pem' -CertificateKey './key.pem' -CertificatePassword '' +$cert = Import-PodeCertificate -Path "C:\Certs\signed-cert.pfx" -CertificatePassword (ConvertTo-SecureString "MyPass" -AsPlainText -Force) +if (-not (Test-PodeCertificate -Certificate $cert -ErrorAction Stop)) { + throw 'Certificate not valid' } ``` -Depending on how you generated the certificate, especially if you used the above openssl, you might have to install the certificate to your local certificate store for it to be trusted. If you're using `Invoke-WebRequest` or `Invoke-RestMethod` on PowerShell 6+ you can supply the `-SkipCertificateCheck` switch. +Alternatively, you can use: + +```powershell +Test-PodeCertificate -Certificate $cert -ErrorAction Stop | Out-Null +``` + +to force an exception if the certificate fails validation. +**Refer to the `Test-PodeCertificate` documentation for any parameter details.** + +--- + +## Exporting a Certificate + +Pode allows exporting certificates in various formats such as PFX and PEM. To export a certificate: + +```powershell +Export-PodeCertificate -Certificate $cert -FilePath "C:\Certs\mycert" -Format "PFX" -CertificatePassword (ConvertTo-SecureString "MyPass" -AsPlainText -Force) +``` + +or as a PEM file with a separate private key: -### Thumbprint +```powershell +Export-PodeCertificate -Certificate $cert -FilePath "C:\Certs\mycert" -Format "PEM" -IncludePrivateKey +``` + +## Checking a Certificate’s Purpose + +A certificate's **purpose** is defined by its **Enhanced Key Usage (EKU)** attributes, which specify what the certificate is allowed to be used for. Common EKU values include: -On Windows only, you can use a certificate that is installed at `Cert:\CurrentUser\My` using its thumbprint: +- `ServerAuth` – Used for server authentication in HTTPS. +- `ClientAuth` – Used for client authentication in mutual TLS setups. +- `CodeSigning` – Used for digitally signing software and scripts. +- `EmailSecurity` – Used for securing email communication. + +Pode can extract the EKU of a certificate to determine its intended purposes: ```powershell +$purposes = Get-PodeCertificatePurpose -Certificate $cert +$purposes +``` + +### Enforcing Certificate Purpose + +When Pode validates a certificate, it ensures that the certificate’s EKU matches the expected usage. If a certificate is used for an endpoint but lacks the required EKU (e.g., using a `CodeSigning` certificate for `ServerAuth`), Pode will reject the certificate and fail to bind it to the endpoint. + +For example, if an HTTPS endpoint is created, the certificate **must** include `ServerAuth`: + +```powershell +$cert = New-PodeSelfSignedCertificate -DnsName "example.com" -CertificatePurpose ServerAuth + Start-PodeServer { - Add-PodeEndpoint -Address * -Port 8090 -Protocol Https -CertificateThumbprint '2A623A8DC46ED42A13B27DD045BFC91FDDAEB957' + Add-PodeEndpoint -Address * -Port 8443 -Protocol Https -X509Certificate $cert } ``` -Note: You can change the certificate store used by supplying `-CertificateStoreName` and/or `-CertificateStoreLocation`. +If the certificate lacks the correct EKU, Pode will return an error when attempting to bind it. -### Name +## Importing an Existing Certificate -On Windows only, you can use a certificate that is installed at `Cert:\CurrentUser\My` using its subject name: +To import a certificate from a file or the Windows certificate store: ```powershell -Start-PodeServer { - Add-PodeEndpoint -Address * -Port 8090 -Protocol Https -CertificateName '*.example.com' +$cert = Import-PodeCertificate -Path "C:\Certs\mycert.pfx" -CertificatePassword (ConvertTo-SecureString "MyPass" -AsPlainText -Force) +``` + +If you import a certificate without validating it, you should then call **`Test-PodeCertificate`** to verify that the certificate is valid: + +```powershell +if (-not (Test-PodeCertificate -Certificate $cert -ErrorAction Stop)) { + throw 'Certificate not valid' } ``` -Note: You can change the certificate store used by supplying `-CertificateStoreName` and/or `-CertificateStoreLocation`. +Alternatively, you can force an exception by piping the output: + +```powershell +Test-PodeCertificate -Certificate $cert -ErrorAction Stop | Out-Null +``` + +Refer to the **Test-PodeCertificate** section below for more details on all available parameters. + +--- + +## Testing a Certificate’s Validity + +Pode provides the **`Test-PodeCertificate`** function to validate an **X.509 certificate** and ensure it meets security and usage requirements. -### X509 +### Features of `Test-PodeCertificate` -The following will instead create an X509Certificate, and pass that to the endpoint instead: +- ✅ Checks if the certificate is **within its validity period** (`NotBefore` and `NotAfter`). +- ✅ **Builds the certificate chain** to verify its trust. +- ✅ Supports **online and offline revocation checking** (OCSP/CRL). +- ✅ Allows **optional enforcement of strong cryptographic algorithms**. +- ✅ Provides an option to **reject self-signed certificates**. +- ✅ Optionally checks that the certificate’s Enhanced Key Usage (EKU) matches an **ExpectedPurpose** (with an optional **Strict** mode). + +### Usage Examples + +#### Basic Certificate Validation ```powershell -$cert = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new('./certs/example.cer') -Add-PodeEndpoint -Address * -Port 8443 -Protocol Https -X509Certificate $cert +Test-PodeCertificate -Certificate $cert ``` -### Self-Signed +- Checks if the certificate is currently valid. +- Does **not** check revocation status. -If you are developing/testing a site on HTTPS then Pode can generate and bind quick self-signed certificates. To do this you can pass the `-SelfSigned` switch: +#### Validate Certificate with Online Revocation Checking ```powershell -Start-PodeServer { - Add-PodeEndpoint -Address * -Port 8443 -Protocol Https -SelfSigned -} +Test-PodeCertificate -Certificate $cert -CheckRevocation +``` + +- Uses **OCSP/CRL lookup** to check if the certificate is revoked. + +#### Validate Certificate with Offline (Cached CRL) Revocation Check + +```powershell +Test-PodeCertificate -Certificate $cert -CheckRevocation -OfflineRevocation ``` -You might get a warning in the browser about the certificate, and this is fine. If you're using `Invoke-WebRequest` or `Invoke-RestMethod` on PowerShell 6+ you can supply the `-SkipCertificateCheck` switch. +- Uses **only locally cached CRLs**, making it suitable for air-gapped environments. -## SSL Protocols +#### Allow Certificates with Weak Algorithms -The default allowed SSL protocols are SSL3 and TLS1.2 (or just TLS1.2 on MacOS), but you can change these to any of: SSL2, SSL3, TLS, TLS11, TLS12, TLS13. This is specified in your `server.psd1` configuration file: +```powershell +Test-PodeCertificate -Certificate $cert -AllowWeakAlgorithms +``` + +- Allows the use of certificates with **SHA1, MD5, or RSA-1024**. + +#### Reject Self-Signed Certificates + +```powershell +Test-PodeCertificate -Certificate $cert -DenySelfSigned +``` + +- Fails validation if the certificate **is self-signed**. + +#### Enforce Expected Certificate Purpose + +```powershell +Test-PodeCertificate -Certificate $cert -ExpectedPurpose CodeSigning -Strict +``` + +- Validates that the certificate is explicitly authorized for **CodeSigning**. +- In strict mode, if any unknown EKUs are present, the validation fails. + +--- + +## Using Certificates for JWT Authentication + +Pode supports using X.509 certificates for JWT authentication. You can specify a certificate for signing and verifying JWTs by providing `-X509Certificate` when creating a bearer authentication scheme: ```powershell -@{ - Server = @{ - Ssl= @{ - Protocols = @('TLS', 'TLS11', 'TLS12') - } +$cert = Import-PodeCertificate -Path "C:\Certs\jwt-signing-cert.pfx" -CertificatePassword (ConvertTo-SecureString "MyPass" -AsPlainText -Force) + +Start-PodeServer { + New-PodeAuthBearerScheme ` + -AsJWT ` + -X509Certificate $cert | + Add-PodeAuth -Name 'JWTAuth' -Sessionless -ScriptBlock { + param($token) + + # Validate and extract user details + return @{ User = $user } } } ``` + +Alternatively, you can use a self-signed certificate for development and testing: + +```powershell +Start-PodeServer { + New-PodeAuthBearerScheme ` + -AsJWT ` + -SelfSigned | + Add-PodeAuth -Name 'JWTAuth' -Sessionless -ScriptBlock { + param($token) + + # Validate and extract user details + return @{ User = $user } + } +} +``` + +Using certificates for JWT authentication provides enhanced security by enabling asymmetric signing (RSA/ECDSA) rather than using a shared secret. diff --git a/docs/Tutorials/Ssl.md b/docs/Tutorials/Ssl.md new file mode 100644 index 000000000..1d65ad098 --- /dev/null +++ b/docs/Tutorials/Ssl.md @@ -0,0 +1,85 @@ +# SSL Protocols + +By default, the server chooses the allowed SSL/TLS protocols based on the operating system’s native support. + +For example, on Windows 11 and Windows Server 2022 only TLS 1.2 and TLS 1.3 are enabled, while older systems (such as Windows Vista/Server 2008) allow SSL 2.0 and SSL 3.0. This behavior follows the table below: + +| Operating System | SSL 2.0 | SSL 3.0 | TLS 1.0 | TLS 1.1 | TLS 1.2 | TLS 1.3 | +|------------------------------------|---------------------|---------------------|---------------------|---------------------|--------------------|--------------------| +| Windows Vista / Server 2008 | Enabled | Enabled | Not Supported | Not Supported | Not Supported | Not Supported | +| Windows 7 / Server 2008 R2 | Enabled | Enabled | Disabled | Disabled | Disabled | Not Supported | +| Windows 8 / Server 2012 | Disabled by Default | Enabled by Default | Enabled by Default | Enabled by Default | Enabled by Default | Not Supported | +| Windows 10 (Build 20170 and later) | No | Disabled by Default | Enabled by Default | Enabled by Default | Enabled by Default | Enabled by Default | +| Windows 11 / Server 2022 | No | Disabled by Default | Disabled by Default | Disabled by Default | Enabled by Default | Enabled by Default | +| macOS 10.8 - 10.10 | No | Yes | Yes | Yes | Yes | No | +| macOS 10.11 | No | No | Yes | Yes | Yes | No | +| macOS 10.13 and later | No | No | Yes | Yes | Yes | Yes | +| Linux (OpenSSL 1.0.1 - 1.0.1f) | No | Yes | Yes | Yes | Yes | No | +| Linux (OpenSSL 1.0.1g and later) | No | No | Yes | Yes | Yes | No | +| Linux (OpenSSL 1.1.1 and later) | No | No | Yes | Yes | Yes | Yes | + +**Notes:** + +- **Windows Operating Systems:** + - TLS 1.3 is supported starting from Windows 10 Build 20170 and Windows Server 2022. + - Earlier versions (like Windows 7 and Windows Server 2008 R2) support up to TLS 1.2, but may require manual configuration to enable it. + +- **macOS:** + - TLS 1.3 support begins with macOS 10.13. + +- **Linux:** + - The supported SSL/TLS protocols on Linux systems depend on the version of OpenSSL installed: + - OpenSSL versions 1.0.1 to 1.0.1f support up to TLS 1.2, with SSL 3.0 enabled by default. + - OpenSSL version 1.0.1g and later disable SSL 3.0 by default. + - OpenSSL version 1.1.1 and later add support for TLS 1.3. + +## Override the Default Values + +If you wish to override the defaults, you can customize the allowed protocols in your `server.psd1` configuration file. For example, if you want to allow only TLS protocols (excluding the deprecated SSL versions), you can configure it as follows: + +```powershell +@{ + Server = @{ + Ssl = @{ + Protocols = @('Tls', 'Tls11', 'Tls12') + } + } +} +``` + +Or, to include TLS 1.3 where supported: + +```powershell +@{ + Server = @{ + Ssl = @{ + Protocols = @('Tls', 'Tls11', 'Tls12', 'Tls13') + } + } +} +``` + +This configuration allows you to explicitly set the protocols from the following list of supported values: `'Ssl2'`, `'Ssl3'`, `'Tls'`, `'Tls11'`, `'Tls12'`, and `'Tls13'`. + +> **Important:** Overriding these default values in your configuration file does **not** automatically enable the corresponding protocols at the operating system level. The OS may block a protocol unless its native settings are also changed. In other words, even if you add `'Ssl3'` to your allowed protocols, Windows 11 will still reject SSLv3 connections unless you modify the OS settings. + +### Example: Enabling SSLv3 on Windows 11 + +By default, Windows 11 disables SSLv3 in its Schannel settings. To enable SSLv3, you need to change the registry settings. **Proceed with caution** as enabling SSLv3 can expose your system to known vulnerabilities (such as the POODLE attack). + +You can enable SSLv3 on Windows 11 using PowerShell as follows: + +```powershell +# Create the registry keys for SSL 3.0 if they don't already exist +New-Item -Path "HKLM:\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL\Protocols\SSL 3.0" -Force | Out-Null +New-Item -Path "HKLM:\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL\Protocols\SSL 3.0\Client" -Force | Out-Null +New-Item -Path "HKLM:\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL\Protocols\SSL 3.0\Server" -Force | Out-Null + +# Enable SSLv3 for both client and server by setting the Enabled DWORD to 1 +Set-ItemProperty -Path "HKLM:\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL\Protocols\SSL 3.0\Client" -Name "Enabled" -Value 1 -Type DWord +Set-ItemProperty -Path "HKLM:\SYSTEM\CurrentControlSet\Control\SecurityProviders\SCHANNEL\Protocols\SSL 3.0\Server" -Name "Enabled" -Value 1 -Type DWord + +Write-Output "SSLv3 has been enabled. A system restart may be required for the changes to take effect." +``` + +After making these changes, your Windows 11 system will accept SSLv3 connections. Remember that this registry modification is an OS-level change, and overriding the configuration in `server.psd1` alone will not suffice. diff --git a/docs/index.md b/docs/index.md index 25d0d9dd9..22b75db5a 100644 --- a/docs/index.md +++ b/docs/index.md @@ -18,36 +18,37 @@ Pode is a Cross-Platform framework to create web servers that host REST APIs, We ## 🚀 Features -* Cross-platform using PowerShell Core (with support for PS5) -* Docker support, including images for ARM/Raspberry Pi -* Azure Functions, AWS Lambda, and IIS support -* OpenAPI specification version 3.0.x and 3.1.0 -* OpenAPI documentation with Swagger, Redoc, RapidDoc, StopLight, OpenAPI-Explorer and RapiPdf -* Listen on a single or multiple IP(v4/v6) addresses/hostnames -* Cross-platform support for HTTP(S), WS(S), SSE, SMTP(S), and TCP(S) -* Host REST APIs, Web Pages, and Static Content (with caching) -* Support for custom error pages -* Request and Response compression using GZip/Deflate -* Multi-thread support for incoming requests -* Inbuilt template engine, with support for third-parties -* Async timers for short-running repeatable processes -* Async scheduled tasks using cron expressions for short/long-running processes -* Supports logging to CLI, Files, and custom logic for other services like LogStash -* Cross-state variable access across multiple runspaces -* Restart the server via file monitoring, or defined periods/times -* Ability to allow/deny requests from certain IP addresses and subnets -* Basic rate limiting for IP addresses and subnets -* Middleware and Sessions on web servers, with Flash message and CSRF support -* Authentication on requests, such as Basic, Windows and Azure AD -* Authorisation support on requests, using Roles, Groups, Scopes, etc. -* Support for dynamically building Routes from Functions and Modules -* Generate/bind self-signed certificates -* Secret management support to load secrets from vaults -* Support for File Watchers -* In-memory caching, with optional support for external providers (such as Redis) -* (Windows) Open the hosted server as a desktop application -* FileBrowsing support -* Localization (i18n) in Arabic, German, Spanish, France, Italian, Japanese, Korean, Polish, Portuguese, and Chinese +* ✅ Cross-platform using PowerShell Core (with support for PS5) +* ✅ Docker support, including images for ARM/Raspberry Pi +* ✅ Azure Functions, AWS Lambda, and IIS support +* ✅ OpenAPI specification version 3.0.x and 3.1.0 +* ✅ OpenAPI documentation with Swagger, Redoc, RapidDoc, StopLight, OpenAPI-Explorer and RapiPdf +* ✅ Listen on a single or multiple IP(v4/v6) addresses/hostnames +* ✅ Cross-platform support for HTTP(S), WS(S), SSE, SMTP(S), and TCP(S) +* ✅ Host REST APIs, Web Pages, and Static Content (with caching) +* ✅ Support for custom error pages +* ✅ Request and Response compression using GZip/Deflate +* ✅ Multi-thread support for incoming requests +* ✅ Inbuilt template engine, with support for third-parties +* ✅ Async timers for short-running repeatable processes +* ✅ Async scheduled tasks using cron expressions for short/long-running processes +* ✅ Supports logging to CLI, Files, and custom logic for other services like LogStash +* ✅ Cross-state variable access across multiple runspaces +* ✅ Restart the server via file monitoring, or defined periods/times +* ✅ Ability to allow/deny requests from certain IP addresses and subnets +* ✅ Basic rate limiting for IP addresses and subnets +* ✅ Middleware and Sessions on web servers, with Flash message and CSRF support +* ✅ Authentication on requests, such as Basic, Windows and Azure AD +* ✅ Authorisation support on requests, using Roles, Groups, Scopes, etc. +* ✅ Enhanced authentication support, including Basic, Bearer (with JWT), Certificate, Digest, Form, OAuth2, and ApiKey (with JWT). +* ✅ Support for dynamically building Routes from Functions and Modules +* ✅ Generate/bind self-signed certificates +* ✅ Secret management support to load secrets from vaults +* ✅ Support for File Watchers +* ✅ In-memory caching, with optional support for external providers (such as Redis) +* ✅ (Windows) Open the hosted server as a desktop application +* ✅ FileBrowsing support +* ✅ Localization (i18n) in Arabic, German, Spanish, France, Italian, Japanese, Korean, Polish, Portuguese,Dutch and Chinese ## 🏢 Companies using Pode diff --git a/examples/Authentication/Modules/Invoke-Digest.psm1 b/examples/Authentication/Modules/Invoke-Digest.psm1 new file mode 100644 index 000000000..07672527f --- /dev/null +++ b/examples/Authentication/Modules/Invoke-Digest.psm1 @@ -0,0 +1,662 @@ +function ConvertTo-Hash { + param ( + [string]$Value, + [string]$Algorithm + ) + + $crypto = switch ($Algorithm) { + 'MD5' { [System.Security.Cryptography.MD5]::Create() } + 'SHA-1' { [System.Security.Cryptography.SHA1]::Create() } + 'SHA-256' { [System.Security.Cryptography.SHA256]::Create() } + 'SHA-384' { [System.Security.Cryptography.SHA384]::Create() } + 'SHA-512' { [System.Security.Cryptography.SHA512]::Create() } + 'SHA-512/256' { + # Compute SHA-512 and take first 32 bytes (256 bits) + $sha512 = [System.Security.Cryptography.SHA512]::Create() + $fullHash = $sha512.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($Value)) + return [System.BitConverter]::ToString($fullHash[0..31]).Replace('-', '').ToLowerInvariant() + } + } + + return [System.BitConverter]::ToString($crypto.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($Value))).Replace('-', '').ToLowerInvariant() +} + +function ChallengeDigest { + param( + [Parameter(Mandatory = $true)] + [ValidateSet('Connect', 'Delete', 'Get', 'Head', 'Merge', 'Options', 'Patch', 'Post', 'Put', 'Trace')] + [string]$Method, + + [Parameter(Mandatory = $true)] + [string]$Uri + ) + # Create an HTTP client + $handler = [System.Net.Http.HttpClientHandler]::new() + $httpClient = [System.Net.Http.HttpClient]::new($handler) + + # Step 1: Send an initial request to get the challenge + $initialRequest = [System.Net.Http.HttpRequestMessage]::new([System.Net.Http.HttpMethod]::$Method, $Uri) + $initialResponse = $httpClient.SendAsync($initialRequest).Result + if ($null -eq $initialResponse) { + Throw "Server $Uri is not responding" + } + + # Extract WWW-Authenticate headers safely + $wwwAuthHeaders = $initialResponse.Headers.GetValues('WWW-Authenticate') + # Filter to get only the Digest authentication scheme + $wwwAuthHeader = $wwwAuthHeaders | Where-Object { $_ -match '^Digest' } + + Write-Verbose 'Extracted WWW-Authenticate headers:' + $wwwAuthHeaders | ForEach-Object { Write-Verbose " - $_" } + + if (-not $wwwAuthHeader) { + Throw 'Digest authentication not supported by server!' + } + + # Extract Digest Authentication challenge values + $challenge = @{} + + if ($wwwAuthHeader -match '^Digest ') { + $headerContent = $wwwAuthHeader -replace '^Digest ', '' + Write-Verbose "RAW HEADER: $headerContent" + + # 1) CAPTURE supported algorithms + if ($headerContent -match 'algorithm=((?:SHA-1|SHA-256|SHA-384|SHA-512(?:/256)?|MD5)(?:,\s*(?:SHA-1|SHA-256|SHA-384|SHA-512(?:/256)?|MD5))*)') { + $algorithms = ($matches[1] -split '\s*,\s*') + Write-Verbose "Supported Algorithms: $algorithms" + $challenge['algorithm'] = $algorithms + } + + # 2) REMOVE algorithm parameter + $headerContent = $headerContent -replace 'algorithm=(?:SHA-1|SHA-256|SHA-384|SHA-512(?:/256)?|MD5)(?:,\s*(?:SHA-1|SHA-256|SHA-384|SHA-512(?:/256)?|MD5))*\s*,?', '' + # 3) CLEAN UP extra commas/whitespace + $headerContent = $headerContent -replace ',\s*,', ',' + $headerContent = $headerContent -replace '^\s*,', '' + + # Split remaining parameters safely + $headerContent -split ', ' | ForEach-Object { + $key, $value = $_ -split '=', 2 + if ($key -and $value) { + $challenge[$key.Trim()] = $value.Trim('"') + } + } + } + + Write-Verbose 'Extracted Digest Authentication Challenge:' + $challenge.GetEnumerator() | ForEach-Object { Write-Verbose "$($_.Key) = $($_.Value)" } + + $realm = $challenge['realm'] + $nonce = $challenge['nonce'] + $qop = $challenge['qop'] + $algorithm = $challenge['algorithm'] + + if (('Post', 'Put', 'Patch') -contains $Method) { + if ($qop -eq 'auth-int' -or $qop -eq 'auth,auth-int') { + $qop = 'auth-int' + } + else { + $qop = 'auth' + } + } + else { + if ($qop -eq 'auth' -or $qop -eq 'auth,auth-int') { + $qop = 'auth' + } + else { + throw "$Method doesn't support QualityOfProtection 'auth-int'" + } + } + + Write-Verbose "Selected QOP: $qop" + + $preferredAlgorithms = @('SHA-512/256', 'SHA-512', 'SHA-384', 'SHA-256', 'SHA-1', 'MD5') + if ($algorithm -isnot [System.Array]) { + $algorithm = @($algorithm) + } + $algorithm = ($preferredAlgorithms | Where-Object { $algorithm -contains $_ } | Select-Object -First 1) + if (-not $algorithm) { + Throw "No supported algorithms found! Server supports: $algorithm" + } + return [PSCustomObject]@{ + realm = $realm + nonce = $nonce + qop = $qop + algorithm = $algorithm + wwwAuthHeader = $wwwAuthHeader + uri = $Uri + httpClient = $httpClient + method = $Method + } +} + +<# +.SYNOPSIS + Sends an HTTP request using Digest authentication and returns a web response. + +.DESCRIPTION + The Invoke-WebRequestDigest function performs an HTTP request with Digest authentication, + handling HTTP headers, authentication challenges, retries, and timeouts. + It returns a BasicHtmlWebResponseObject similar to Invoke-WebRequest. + +.PARAMETER Uri + The target URI for the request. + +.PARAMETER Method + The HTTP method to use for the request. Default is 'GET'. + +.PARAMETER Body + The request body, required for methods like POST, PUT, and PATCH. + +.PARAMETER Credential + The PSCredential object containing the username and password for Digest authentication. + +.PARAMETER Headers + A hashtable of additional headers to include in the request. + +.PARAMETER ContentType + The Content-Type of the request body. Default is 'application/json'. + +.PARAMETER OperationTimeoutSeconds + The maximum time in seconds before the request times out. Default is 100. + +.PARAMETER ConnectionTimeoutSeconds + The timeout in seconds for establishing a connection. Default is 100. + +.PARAMETER DisableKeepAlive + If specified, disables persistent connections by adding the 'Connection: close' header. + +.PARAMETER HttpVersion + The HTTP version to use, such as '1.1' or '2.0'. Default is '1.1'. + +.PARAMETER MaximumRetryCount + The number of times to retry the request in case of failure. Default is 1. + +.PARAMETER RetryIntervalSec + The interval in seconds between retry attempts. Default is 1. + +.PARAMETER OutFile + If specified, writes the response body to the specified file instead of returning content. + +.PARAMETER PassThru + If specified, returns the response object even if OutFile is used. + +.PARAMETER SkipCertificateCheck + If specified, disables SSL certificate validation (useful for self-signed certificates). + +.PARAMETER SslProtocol + Specifies the allowed SSL/TLS protocol(s) to use (e.g., 'Tls12'). + +.PARAMETER TransferEncoding + The value for the 'Transfer-Encoding' header. + +.PARAMETER UserAgent + The User-Agent string to use in the request. + +.OUTPUTS + - Returns a [Microsoft.PowerShell.Commands.BasicHtmlWebResponseObject]. + - If OutFile is specified, writes response data to the specified file. + +.EXAMPLE + $cred = Get-Credential + $response = Invoke-WebRequestDigest -Uri 'https://example.com/data' -Method 'GET' -Credential $cred + Write-Output $response.Content + +.EXAMPLE + $body = @{ "name" = "John Doe"; "email" = "john@example.com" } + $cred = Get-Credential + $response = Invoke-WebRequestDigest -Uri 'https://example.com/users' -Method 'POST' -Credential $cred -Body $body -ContentType 'application/json' + Write-Output $response.Content + +.EXAMPLE + # Download file + Invoke-WebRequestDigest -Uri 'https://example.com/file.zip' -Method 'GET' -Credential $cred -OutFile 'C:\Downloads\file.zip' + +.NOTES + - This function provides full control over HTTP requests with Digest authentication. + - Supports custom headers, connection options, timeouts, and retries. + - Unlike Invoke-RestMethodDigest, this function does not automatically parse JSON/XML. +#> +function Invoke-WebRequestDigest { + [CmdletBinding(DefaultParameterSetName = 'Uri')] + [OutputType([Microsoft.PowerShell.Commands.BasicHtmlWebResponseObject])] + param( + # URI of the request (required) + [Parameter(Mandatory = $true, Position = 0)] + [Uri]$Uri, + + # HTTP method (default GET) + [Parameter(Position = 1)] + [ValidateSet('GET', 'POST', 'PUT', 'DELETE', 'HEAD', 'OPTIONS', 'TRACE', 'PATCH', 'MERGE', 'CONNECT')] + [string]$Method = 'GET', + + # Request body (for POST/PUT/PATCH, etc.) + [Parameter()] + $Body, + + # Credential for Digest authentication (required) + [Parameter(Mandatory = $true)] + [System.Management.Automation.PSCredential]$Credential, + + # Additional headers (as a hashtable) + [Parameter()] + [hashtable]$Headers, + + # Content type for the request body (default application/json) + [Parameter()] + [string]$ContentType = 'application/json', + + # Timeout (for the overall operation) in seconds + [Parameter()] + [int]$OperationTimeoutSeconds = 100, + + # Connection timeout in seconds + [Parameter()] + [int]$ConnectionTimeoutSeconds = 100, + + # Disable persistent connections (KeepAlive) + [Parameter()] + [switch]$DisableKeepAlive, + + # Specify the HTTP version (e.g. '1.1' or '2.0') + [Parameter()] + [string]$HttpVersion = '1.1', + + # Maximum number of retries (if request fails) + [Parameter()] + [int]$MaximumRetryCount = 1, + + # Interval between retries (seconds) + [Parameter()] + [int]$RetryIntervalSec = 1, + + # If provided, write response body to this file + [Parameter()] + [string]$OutFile, + + # If specified, output the response object even if OutFile is used + [Parameter()] + [switch]$PassThru, + + # Skip certificate validation (useful for self-signed certs) + [Parameter()] + [switch]$SkipCertificateCheck, + + # Specify allowed SSL/TLS protocol(s) (e.g. 'Tls12') + [Parameter()] + [string]$SslProtocol, + + # Transfer-Encoding header value to set on the request + [Parameter()] + [string]$TransferEncoding, + + # User-Agent string to use on the request + [Parameter()] + [string]$UserAgent + ) + + # Validate that we have a credential + if (-not $Credential) { + Throw 'A credential is required for Digest authentication.' + } + + # Use HttpClientHandler + $handler = [System.Net.Http.HttpClientHandler]::new() + if ($SkipCertificateCheck) { + $handler.ServerCertificateCustomValidationCallback = { return $true } + } + if ($SslProtocol) { + $handler.SslProtocols = [System.Enum]::Parse( + [System.Security.Authentication.SslProtocols], $SslProtocol) + } + $httpClient = [System.Net.Http.HttpClient]::new($handler) + + $httpClient.Timeout = [TimeSpan]::FromSeconds($ConnectionTimeoutSeconds) + + # If DisableKeepAlive is specified, add a header to close the connection. + if ($DisableKeepAlive) { + if (-not $Headers) { $Headers = @{} } + $Headers['Connection'] = 'close' + } + + # Use the challenge function to get the digest details. + try { + $challenge = ChallengeDigest -Uri $Uri -Method $Method + } + catch { + Throw "Error retrieving Digest authentication challenge: $_" + } + + try { + # If a body is provided and content type is JSON, convert it if necessary. + if ($Body -and ($ContentType -match 'application/json')) { + if ($Body -isnot [string]) { + $Body = $Body | ConvertTo-Json -Compress + } + } + + # Build the digest response parameters. + $nc = '00000001' + $cnonce = (New-Guid).Guid.Substring(0, 8) + $Method = $challenge.Method.ToUpper() + Write-Verbose "Using method: $Method" + $uriPath = ([System.Uri]$challenge.uri).AbsolutePath + + # Compute HA1 + $HA1 = ConvertTo-Hash -Value "$($Credential.UserName):$($challenge.realm):$($Credential.GetNetworkCredential().Password)" -Algorithm $challenge.algorithm + + if ($challenge.qop -eq 'auth-int') { + if (('Post', 'Put', 'Patch') -notcontains $Method) { + Throw "'auth-int' doesn't support $Method" + } + $requestBody = $Body | ConvertTo-Json + $entityBodyHash = ConvertTo-Hash -Value $requestBody -Algorithm $challenge.algorithm + $HA2 = ConvertTo-Hash -Value "$($Method):$($uriPath):$($entityBodyHash)" -Algorithm $challenge.algorithm + } + else { + $HA2 = ConvertTo-Hash -Value "$($Method):$($uriPath)" -Algorithm $challenge.algorithm + } + + $responseHash = ConvertTo-Hash -Value "$($HA1):$($challenge.nonce):$($nc):$($cnonce):$($challenge.qop):$HA2" -Algorithm $challenge.algorithm + + # Build the Authorization header using StringBuilder. + $sb = [System.Text.StringBuilder]::new() + [void]$sb.Append('Digest username="').Append($Credential.UserName).Append('"') + [void]$sb.Append(', realm="').Append($challenge.realm).Append('"') + [void]$sb.Append(', nonce="').Append($challenge.nonce).Append('"') + [void]$sb.Append(', uri="').Append($uriPath).Append('"') + [void]$sb.Append(', algorithm=').Append($challenge.algorithm) + [void]$sb.Append(', response="').Append($responseHash).Append('"') + [void]$sb.Append(', qop="').Append($challenge.qop).Append('"') + [void]$sb.Append(', nc=').Append($nc) + [void]$sb.Append(', cnonce="').Append($cnonce).Append('"') + + # Create the HttpRequestMessage. + $authRequest = [System.Net.Http.HttpRequestMessage]::new([System.Net.Http.HttpMethod]::$Method, $challenge.uri) + $authRequest.Headers.Authorization = [System.Net.Http.Headers.AuthenticationHeaderValue]::new('Digest', $sb.ToString()) + + # Set the HTTP version if provided. + if ($HttpVersion) { + $authRequest.Version = [System.Version]$HttpVersion + } + + # Add additional headers (if any) to the request. + if ($Headers) { + foreach ($key in $Headers.Keys) { + $authRequest.Headers.TryAddWithoutValidation($key, $Headers[$key]) | Out-Null + } + } + + # Set Transfer-Encoding if provided. + if ($TransferEncoding) { + $authRequest.Headers.TryAddWithoutValidation('Transfer-Encoding', $TransferEncoding) | Out-Null + } + + # Set User-Agent if provided. + if ($UserAgent) { + $authRequest.Headers.UserAgent.Clear() + $authRequest.Headers.UserAgent.ParseAdd($UserAgent) + } + + if ($challenge.qop -eq 'auth-int') { + $authRequest.Content = [System.Net.Http.StringContent]::new($requestBody, [System.Text.Encoding]::UTF8, $ContentType) + } + + # Implement a simple retry loop. + $retryCount = 0 + do { + try { + $rawResponse = $challenge.httpClient.SendAsync($authRequest).Result + break + } + catch { + if (++$retryCount -ge $MaximumRetryCount) { + Throw "Error sending the authenticated request after $MaximumRetryCount attempts: $_" + } + else { + Write-Verbose "Retrying in $RetryIntervalSec seconds..." + Start-Sleep -Seconds $RetryIntervalSec + } + } + } while ($true) + + # Optionally write response to file. + if ($OutFile) { + $mediaType = $rawResponse.Content.Headers.ContentType.MediaType + if ($mediaType -match '^(text|application/json|application/xml)') { + $contentString = $rawResponse.Content.ReadAsStringAsync().Result + Set-Content -Path $OutFile -Value $contentString -Encoding UTF8 + } + else { + $rawResponse.Content.ReadAsByteArrayAsync().Result | Set-Content -Path $OutFile -Encoding Byte + } + if (-not $PassThru) { return } + } + + # Wrap the response in a BasicHtmlWebResponseObject using the OperationTimeoutSeconds value. + $contentStream = $rawResponse.Content.ReadAsStream() + $timeout = [TimeSpan]::FromSeconds($OperationTimeoutSeconds) + $cancellationToken = [System.Threading.CancellationToken]::None + return [Microsoft.PowerShell.Commands.BasicHtmlWebResponseObject]::new($rawResponse, $contentStream, $timeout, $cancellationToken) + } + catch { + Throw "Error sending Digest authenticated request: $_" + } +} + + +<# +.SYNOPSIS + Sends an HTTP or REST request using Digest authentication and returns parsed data. + +.DESCRIPTION + The Invoke-RestMethodDigest function performs an HTTP request with Digest authentication, + leveraging Invoke-WebRequestDigest under the hood. It automatically parses the response + content into an object, supporting JSON and XML formats. + +.PARAMETER Uri + The target URI for the request. + +.PARAMETER Method + The HTTP method to use for the request. Default is 'GET'. + +.PARAMETER Body + The request body, required for methods like POST, PUT, and PATCH. + +.PARAMETER Credential + The PSCredential object containing the username and password for Digest authentication. + +.PARAMETER Headers + A hashtable of additional headers to include in the request. + +.PARAMETER ContentType + The Content-Type of the request body. Default is 'application/json'. + +.PARAMETER OperationTimeoutSeconds + The maximum time in seconds before the request times out. Default is 100. + +.PARAMETER ConnectionTimeoutSeconds + The timeout in seconds for establishing a connection. Default is 100. + +.PARAMETER DisableKeepAlive + If specified, disables persistent connections by adding the 'Connection: close' header. + +.PARAMETER HttpVersion + The HTTP version to use, such as '1.1' or '2.0'. Default is '1.1'. + +.PARAMETER MaximumRetryCount + The number of times to retry the request in case of failure. Default is 1. + +.PARAMETER RetryIntervalSec + The interval in seconds between retry attempts. Default is 1. + +.PARAMETER OutFile + If specified, writes the response body to the specified file instead of returning content. + +.PARAMETER PassThru + If specified, returns the response object even if OutFile is used. + +.PARAMETER SkipCertificateCheck + If specified, disables SSL certificate validation (useful for self-signed certificates). + +.PARAMETER SslProtocol + Specifies the allowed SSL/TLS protocol(s) to use (e.g., 'Tls12'). + +.PARAMETER TransferEncoding + The value for the 'Transfer-Encoding' header. + +.PARAMETER UserAgent + The User-Agent string to use in the request. + +.OUTPUTS + - JSON responses are converted to PowerShell objects. + - XML responses are parsed into XML objects. + - Plain text or other data is returned as-is. + +.EXAMPLE + $cred = Get-Credential + $response = Invoke-RestMethodDigest -Uri 'https://example.com/api/data' -Method 'GET' -Credential $cred + Write-Output $response + +.EXAMPLE + $body = @{ "name" = "John Doe"; "email" = "john@example.com" } + $cred = Get-Credential + $response = Invoke-RestMethodDigest -Uri 'https://example.com/api/users' -Method 'POST' -Credential $cred -Body $body -ContentType 'application/json' + Write-Output $response + +.NOTES + - This function is a wrapper around Invoke-WebRequestDigest and provides an easier way + to work with REST APIs by automatically parsing the response content. + - Use Invoke-WebRequestDigest if you need full access to response headers and raw content. +#> +function Invoke-RestMethodDigest { + [CmdletBinding(DefaultParameterSetName = 'Uri')] + [OutputType([xml])] + [OutputType([psobject])] + param( + # URI of the request (required) + [Parameter(Mandatory = $true, Position = 0)] + [Uri]$Uri, + + # HTTP method (default GET) + [Parameter(Position = 1)] + [ValidateSet('GET', 'POST', 'PUT', 'DELETE', 'HEAD', 'OPTIONS', 'TRACE', 'PATCH', 'MERGE', 'CONNECT')] + [string]$Method = 'GET', + + # Request body (for POST/PUT/PATCH, etc.) + [Parameter()] + $Body, + + # Credential for Digest authentication (required) + [Parameter(Mandatory = $true)] + [System.Management.Automation.PSCredential]$Credential, + + # Additional headers (as a hashtable) + [Parameter()] + [hashtable]$Headers, + + # Content type for the request body (default application/json) + [Parameter()] + [string]$ContentType = 'application/json', + + # Timeout (for the overall operation) in seconds + [Parameter()] + [int]$OperationTimeoutSeconds = 100, + + # Connection timeout in seconds + [Parameter()] + [int]$ConnectionTimeoutSeconds = 100, + + # Disable persistent connections (KeepAlive) + [Parameter()] + [switch]$DisableKeepAlive, + + # Specify the HTTP version (e.g. '1.1' or '2.0') + [Parameter()] + [string]$HttpVersion = '1.1', + + # Maximum number of retries (if request fails) + [Parameter()] + [int]$MaximumRetryCount = 1, + + # Interval between retries (seconds) + [Parameter()] + [int]$RetryIntervalSec = 1, + + # If provided, write response body to this file + [Parameter()] + [string]$OutFile, + + # If specified, output the response object even if OutFile is used + [Parameter()] + [switch]$PassThru, + + # Skip certificate validation (useful for self-signed certs) + [Parameter()] + [switch]$SkipCertificateCheck, + + # Specify allowed SSL/TLS protocol(s) (e.g. 'Tls12') + [Parameter()] + [string]$SslProtocol, + + + # Transfer-Encoding header value + [Parameter()] + [string]$TransferEncoding, + + # User-Agent string to use on the request + [Parameter()] + [string]$UserAgent + ) + + # Build a parameter hashtable for Invoke-WebRequestDigest + $params = @{ + Uri = $Uri + Method = $Method + Body = $Body + Credential = $Credential + Headers = $Headers + ContentType = $ContentType + OperationTimeoutSeconds = $OperationTimeoutSeconds + ConnectionTimeoutSeconds = $ConnectionTimeoutSeconds + DisableKeepAlive = $DisableKeepAlive + HttpVersion = $HttpVersion + MaximumRetryCount = $MaximumRetryCount + RetryIntervalSec = $RetryIntervalSec + OutFile = $OutFile + PassThru = $PassThru + SkipCertificateCheck = $SkipCertificateCheck + SslProtocol = $SslProtocol + TransferEncoding = $TransferEncoding + UserAgent = $UserAgent + } + + # Call the digest-enabled web request function + $webResponse = Invoke-WebRequestDigest @params + + if ($null -eq $webResponse) { + return $null + } + + # Parse the response content based on its media type + $content = $webResponse.Content + if ($content) { + # Get Content-Type header if available + $mediaType = $webResponse.Headers.'Content-Type' + if ($mediaType -match 'application/json') { + return $content | ConvertFrom-Json + } + elseif ($mediaType -match 'application/xml' -or $mediaType -match 'text/xml') { + return [xml]$content + } + else { + # For non-parsed content (plain text or other formats) + return $content + } + } + else { + return $null + } +} + +Export-ModuleMember -Function Invoke-WebRequestDigest +Export-ModuleMember -Function Invoke-RestMethodDigest diff --git a/examples/WebAuth-ApikeyJWT.ps1 b/examples/Authentication/Web-AuthApiKey.ps1 similarity index 94% rename from examples/WebAuth-ApikeyJWT.ps1 rename to examples/Authentication/Web-AuthApiKey.ps1 index dcf242d54..e7b88e135 100644 --- a/examples/WebAuth-ApikeyJWT.ps1 +++ b/examples/Authentication/Web-AuthApiKey.ps1 @@ -29,7 +29,7 @@ Invoke-RestMethod -Uri http://localhost:8081/users -Method Get .LINK - https://github.com/Badgerati/Pode/blob/develop/examples/WebAuth-ApikeyJWT.ps1 + https://github.com/Badgerati/Pode/blob/develop/examples/Authentication/WebAuth-ApikeyJWT.ps1 .NOTES Author: Pode Team @@ -44,7 +44,7 @@ param( try { # Determine the script path and Pode module path - $ScriptPath = (Split-Path -Parent -Path $MyInvocation.MyCommand.Path) + $ScriptPath = (Split-Path -Parent -Path (Split-Path -Parent -Path $MyInvocation.MyCommand.Path)) $podePath = Split-Path -Parent -Path $ScriptPath # Import the Pode module from the source path if it exists, otherwise from installed modules diff --git a/examples/Web-AuthBasic.ps1 b/examples/Authentication/Web-AuthBasic.ps1 similarity index 93% rename from examples/Web-AuthBasic.ps1 rename to examples/Authentication/Web-AuthBasic.ps1 index e0a886a02..ef795df87 100644 --- a/examples/Web-AuthBasic.ps1 +++ b/examples/Authentication/Web-AuthBasic.ps1 @@ -23,7 +23,7 @@ Invoke-RestMethod -Uri http://localhost:8081/users -Method Post -Headers @{ Authorization = 'Basic bW9ydHk6cmljaw==' } .LINK - https://github.com/Badgerati/Pode/blob/develop/examples/Web-AuthBasic.ps1 + https://github.com/Badgerati/Pode/blob/develop/examples/Authentication/Web-AuthBasic.ps1 .NOTES Author: Pode Team @@ -31,7 +31,7 @@ #> try { # Determine the script path and Pode module path - $ScriptPath = (Split-Path -Parent -Path $MyInvocation.MyCommand.Path) + $ScriptPath = (Split-Path -Parent -Path (Split-Path -Parent -Path $MyInvocation.MyCommand.Path)) $podePath = Split-Path -Parent -Path $ScriptPath # Import the Pode module from the source path if it exists, otherwise from installed modules @@ -75,7 +75,7 @@ Start-PodeServer -Threads 2 { return @{ Message = 'Invalid details supplied' } } - + # POST request to get current user (since there's no session, authentication will always happen) Add-PodeRoute -Method Post -Path '/users' -Authentication 'Validate' -ScriptBlock { Write-PodeJsonResponse -Value @{ diff --git a/examples/Web-AuthBasicAccess.ps1 b/examples/Authentication/Web-AuthBasicAccess.ps1 similarity index 96% rename from examples/Web-AuthBasicAccess.ps1 rename to examples/Authentication/Web-AuthBasicAccess.ps1 index 1bd7d36c5..0c07afe5d 100644 --- a/examples/Web-AuthBasicAccess.ps1 +++ b/examples/Authentication/Web-AuthBasicAccess.ps1 @@ -27,7 +27,7 @@ Invoke-RestMethod -Uri http://localhost:8081/users-all -Method Post -Headers @{ Authorization = 'Basic bW9ydHk6cmljaw==' } .LINK - https://github.com/Badgerati/Pode/blob/develop/examples/Dot-SourceScript.ps1 + https://github.com/Badgerati/Pode/blob/develop/examples/Authentication/Dot-SourceScript.ps1 .NOTES Author: Pode Team @@ -35,7 +35,7 @@ #> try { # Determine the script path and Pode module path - $ScriptPath = (Split-Path -Parent -Path $MyInvocation.MyCommand.Path) + $ScriptPath = (Split-Path -Parent -Path (Split-Path -Parent -Path $MyInvocation.MyCommand.Path)) $podePath = Split-Path -Parent -Path $ScriptPath # Import the Pode module from the source path if it exists, otherwise from installed modules diff --git a/examples/Web-AuthBasicAdhoc.ps1 b/examples/Authentication/Web-AuthBasicAdhoc.ps1 similarity index 94% rename from examples/Web-AuthBasicAdhoc.ps1 rename to examples/Authentication/Web-AuthBasicAdhoc.ps1 index c4eea0b73..b7b905ec5 100644 --- a/examples/Web-AuthBasicAdhoc.ps1 +++ b/examples/Authentication/Web-AuthBasicAdhoc.ps1 @@ -27,7 +27,7 @@ Invoke-RestMethod -Uri http://localhost:8081/users -Method Post -Headers @{ Authorization = 'Basic bW9ydHk6cmljaw==' } .LINK - https://github.com/Badgerati/Pode/blob/develop/examples/Web-AuthBasicAdhoc.ps1 + https://github.com/Badgerati/Pode/blob/develop/examples/Authentication/Web-AuthBasicAdhoc.ps1 .NOTES Author: Pode Team @@ -35,7 +35,7 @@ #> try { # Determine the script path and Pode module path - $ScriptPath = (Split-Path -Parent -Path $MyInvocation.MyCommand.Path) + $ScriptPath = (Split-Path -Parent -Path (Split-Path -Parent -Path $MyInvocation.MyCommand.Path)) $podePath = Split-Path -Parent -Path $ScriptPath # Import the Pode module from the source path if it exists, otherwise from installed modules diff --git a/examples/Web-AuthBasicAnon.ps1 b/examples/Authentication/Web-AuthBasicAnon.ps1 similarity index 94% rename from examples/Web-AuthBasicAnon.ps1 rename to examples/Authentication/Web-AuthBasicAnon.ps1 index 597c3cf9c..2f6648779 100644 --- a/examples/Web-AuthBasicAnon.ps1 +++ b/examples/Authentication/Web-AuthBasicAnon.ps1 @@ -27,7 +27,7 @@ Invoke-RestMethod -Uri http://localhost:8081/users -Headers @{ Authorization = 'Basic bW9ydHk6cmljaw==' } .LINK - https://github.com/Badgerati/Pode/blob/develop/examples/Web-AuthBasicAnon.ps1 + https://github.com/Badgerati/Pode/blob/develop/examples/Authentication/Web-AuthBasicAnon.ps1 .NOTES Author: Pode Team @@ -35,7 +35,7 @@ #> try { # Determine the script path and Pode module path - $ScriptPath = (Split-Path -Parent -Path $MyInvocation.MyCommand.Path) + $ScriptPath = (Split-Path -Parent -Path (Split-Path -Parent -Path $MyInvocation.MyCommand.Path)) $podePath = Split-Path -Parent -Path $ScriptPath # Import the Pode module from the source path if it exists, otherwise from installed modules diff --git a/examples/Web-AuthBasicBearer.ps1 b/examples/Authentication/Web-AuthBasicBearer.ps1 similarity index 80% rename from examples/Web-AuthBasicBearer.ps1 rename to examples/Authentication/Web-AuthBasicBearer.ps1 index f6c73820d..5ccec6948 100644 --- a/examples/Web-AuthBasicBearer.ps1 +++ b/examples/Authentication/Web-AuthBasicBearer.ps1 @@ -9,10 +9,16 @@ .EXAMPLE To run the sample: ./Web-AuthBasicBearer.ps1 - Invoke-RestMethod -Method Get -Uri 'http://localhost:8081/users' -Headers @{ Authorization = 'Bearer test-token' } + Invoke-RestMethod -Method Get -Uri 'http://localhost:8081/users' -Headers @{ Authorization = 'Bearer test-token' } -ResponseHeadersVariable headers + +.EXAMPLE + "No Authorization header found" + + Invoke-RestMethod -Method Get -Uri 'http://localhost:8081/users' -ResponseHeadersVariable headers -Verbose -SkipHttpErrorCheck + $headers | Format-List .LINK - https://github.com/Badgerati/Pode/blob/develop/examples/Web-AuthBasicBearer.ps1 + https://github.com/Badgerati/Pode/blob/develop/examples/Authentication/Web-AuthBasicBearer.ps1 .NOTES Author: Pode Team @@ -20,7 +26,7 @@ #> try { # Determine the script path and Pode module path - $ScriptPath = (Split-Path -Parent -Path $MyInvocation.MyCommand.Path) + $ScriptPath = (Split-Path -Parent -Path (Split-Path -Parent -Path $MyInvocation.MyCommand.Path)) $podePath = Split-Path -Parent -Path $ScriptPath # Import the Pode module from the source path if it exists, otherwise from installed modules @@ -64,7 +70,7 @@ Start-PodeServer -Threads 2 { } # 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' -ErrorContentType 'application/json' -ScriptBlock { Write-PodeJsonResponse -Value @{ Users = @( @{ diff --git a/examples/Web-AuthBasicClientcert.ps1 b/examples/Authentication/Web-AuthBasicClientcert.ps1 similarity index 92% rename from examples/Web-AuthBasicClientcert.ps1 rename to examples/Authentication/Web-AuthBasicClientcert.ps1 index 489b37b72..d99470183 100644 --- a/examples/Web-AuthBasicClientcert.ps1 +++ b/examples/Authentication/Web-AuthBasicClientcert.ps1 @@ -10,7 +10,7 @@ To run the sample: ./Web-AuthBasicClientcert.ps1 .LINK - https://github.com/Badgerati/Pode/blob/develop/examples/Web-AuthBasicClientcert.ps1 + https://github.com/Badgerati/Pode/blob/develop/examples/Authentication/Web-AuthBasicClientcert.ps1 .NOTES Author: Pode Team @@ -18,7 +18,7 @@ #> try { # Determine the script path and Pode module path - $ScriptPath = (Split-Path -Parent -Path $MyInvocation.MyCommand.Path) + $ScriptPath = (Split-Path -Parent -Path (Split-Path -Parent -Path $MyInvocation.MyCommand.Path)) $podePath = Split-Path -Parent -Path $ScriptPath # Import the Pode module from the source path if it exists, otherwise from installed modules diff --git a/examples/Web-AuthBasicHeader.ps1 b/examples/Authentication/Web-AuthBasicHeader.ps1 similarity index 84% rename from examples/Web-AuthBasicHeader.ps1 rename to examples/Authentication/Web-AuthBasicHeader.ps1 index 0fb0f8d27..169d3f445 100644 --- a/examples/Web-AuthBasicHeader.ps1 +++ b/examples/Authentication/Web-AuthBasicHeader.ps1 @@ -17,7 +17,8 @@ The example used here is Basic authentication. Login: - $session = (Invoke-WebRequest -Uri http://localhost:8081/login -Method Post -Headers @{ Authorization = 'Basic bW9ydHk6cGlja2xl' }).Headers['pode.sid'] + Invoke-RestMethod -Uri http://localhost:8081/login -Method Post -Headers @{ Authorization = 'Basic bW9ydHk6cGlja2xl' } -ResponseHeadersVariable headers -SkipHttpErrorCheck + $session = $headers['pode.sid'] Users: Invoke-RestMethod -Uri http://localhost:8081/users -Method Post -Headers @{ 'pode.sid' = "$session" } @@ -26,7 +27,7 @@ Invoke-WebRequest -Uri http://localhost:8081/logout -Method Post -Headers @{ 'pode.sid' = "$session" } .LINK - https://github.com/Badgerati/Pode/blob/develop/examples/Web-AuthBasicHeader.ps1 + https://github.com/Badgerati/Pode/blob/develop/examples/Authentication/Web-AuthBasicHeader.ps1 .NOTES Author: Pode Team @@ -34,7 +35,7 @@ #> try { # Determine the script path and Pode module path - $ScriptPath = (Split-Path -Parent -Path $MyInvocation.MyCommand.Path) + $ScriptPath = (Split-Path -Parent -Path (Split-Path -Parent -Path $MyInvocation.MyCommand.Path)) $podePath = Split-Path -Parent -Path $ScriptPath # Import the Pode module from the source path if it exists, otherwise from installed modules @@ -81,13 +82,13 @@ Start-PodeServer -Threads 2 { } # POST request to login - Add-PodeRoute -Method Post -Path '/login' -Authentication 'Login' + Add-PodeRoute -Method Post -Path '/login' -Authentication 'Login' -ErrorContentType 'application/json' # POST request to logout - Add-PodeRoute -Method Post -Path '/logout' -Authentication 'Login' -Logout + Add-PodeRoute -Method Post -Path '/logout' -Authentication 'Login' -Logout -ErrorContentType 'application/json' # POST request to get list of users - the "pode.sid" header is expected - Add-PodeRoute -Method Post -Path '/users' -Authentication 'Login' -ScriptBlock { + Add-PodeRoute -Method Post -Path '/users' -Authentication 'Login' -ErrorContentType 'application/json' -ScriptBlock { Write-PodeJsonResponse -Value @{ Users = @( @{ diff --git a/examples/Authentication/Web-AuthBearerJWT.ps1 b/examples/Authentication/Web-AuthBearerJWT.ps1 new file mode 100644 index 000000000..1ae85402f --- /dev/null +++ b/examples/Authentication/Web-AuthBearerJWT.ps1 @@ -0,0 +1,248 @@ +<# +.SYNOPSIS + A PowerShell script to set up a Pode server with JWT authentication and various route configurations. + +.DESCRIPTION + This script initializes a Pode server that listens on a specified port, enables request and error logging, + and configures JWT authentication using either the request header or query parameters. It also defines + a protected route to fetch a list of users, requiring authentication. + +.PARAMETER Location + Specifies where the API key (JWT token) is expected. + Valid values: 'Header', 'Query'. + Default: 'Header'. + +.EXAMPLE + # Run the sample + ./WebAuth-bearerJWT.ps1 + + JWT payload: + { + "sub": "1234567890", + "name": "morty", + "username":"morty", + "type": "Human", + "id" : "M0R7Y302", + "admin": true, + "iat": 1516239022, + "exp": 2634234231, + "iss": "auth.example.com", + "sub": "1234567890", + "aud": "myapi.example.com", + "nbf": 1690000000, + "jti": "unique-token-id", + "role": "admin" + } + +.EXAMPLE + # Example request using PS512 JWT authentication + $jwt = ConvertTo-PodeJwt -PfxPath ./cert.pfx -RsaPaddingScheme Pss -PfxPassword (ConvertTo-SecureString 'mySecret' -AsPlainText -Force) + $headers = @{ 'Authorization' = "Bearer $jwt" } + $response = Invoke-RestMethod -Uri 'http://localhost:8081/auth/bearer/jwt/PS512' -Method Get -Headers $headers + +.EXAMPLE + # Example request using RS384 JWT authentication + $headers = @{ 'Authorization' = 'Bearer ' } + $response = Invoke-RestMethod -Uri 'http://localhost:8081/users' -Method Get -Headers $headers + +.EXAMPLE + # Example request using HS256 JWT authentication + $jwt = ConvertTo-PodeJwt -Algorithm HS256 -Secret (ConvertTo-SecureString 'secret' -AsPlainText -Force) -Payload @{id='id';name='Morty'} + $headers = @{ 'Authorization' = "Bearer $jwt" } + $response = Invoke-RestMethod -Uri 'http://localhost:8081/users' -Method Get -Headers $headers + + .LINK + https://github.com/Badgerati/Pode/blob/develop/examples/Authentication/Web-AuthbearerJWT.ps1 + + .NOTES + - This script uses Pode to create a lightweight web server with authentication. + - JWT authentication is handled via Bearer tokens passed in either the header or query. + - Ensure the private key is securely stored and managed for RS256-based JWT signing. + - Using query parameters for authentication is **discouraged** due to security risks. + - Always use HTTPS in production to protect sensitive authentication data. + + Author: Pode Team + License: MIT License +#> + +param( + [Parameter()] + [ValidateSet('Header', 'Query' )] + [string] + $Location = 'Header' +) + +try { + # Determine the script path and Pode module path + $ScriptPath = (Split-Path -Parent -Path (Split-Path -Parent -Path $MyInvocation.MyCommand.Path)) + $podePath = Split-Path -Parent -Path $ScriptPath + + # Import the Pode module from the source path if it exists, otherwise from installed modules + if (Test-Path -Path "$($podePath)/src/Pode.psm1" -PathType Leaf) { + Import-Module "$($podePath)/src/Pode.psm1" -Force -ErrorAction Stop + } + else { + Import-Module -Name 'Pode' -MaximumVersion 2.99 -ErrorAction Stop + } +} +catch { throw } + +# or just: +# Import-Module Pode + +# create a server, and start listening on port 8081 +Start-PodeServer -Threads 2 -ApplicationName 'webauth' { + + # listen on localhost:8081 + Add-PodeEndpoint -Address localhost -Port 8081 -Protocol Http + + New-PodeLoggingMethod -File -Name 'requests' | Enable-PodeRequestLogging + New-PodeLoggingMethod -Terminal | Enable-PodeErrorLogging + + + $JwtVerificationMode = 'Lenient' # Set your desired verification mode (Lenient or Strict) + + $certificateTypes = @{ + 'RS256' = @{ + KeyType = 'RSA' + KeyLength = 2048 + } + 'RS384' = @{ + KeyType = 'RSA' + KeyLength = 3072 + } + 'RS512' = @{ + KeyType = 'RSA' + KeyLength = 4096 + } + 'PS256' = @{ + KeyType = 'RSA' + KeyLength = 2048 + } + 'PS384' = @{ + KeyType = 'RSA' + KeyLength = 3072 + } + 'PS512' = @{ + KeyType = 'RSA' + KeyLength = 4096 + } + 'ES256' = @{ + KeyType = 'ECDSA' + KeyLength = 256 + } + 'ES384' = @{ + KeyType = 'ECDSA' + KeyLength = 384 + } + 'ES512' = @{ + KeyType = 'ECDSA' + KeyLength = 521 + } + } + + $CertsPath = Join-Path -Path (Get-PodeServerPath) -ChildPath "certs" + if (!(Test-Path -Path $CertsPath -PathType Container)) { + New-Item -Path $CertsPath -ItemType Directory + } + foreach ($alg in $certificateTypes.Keys) { + $x509Certificate = New-PodeSelfSignedCertificate -Loopback -KeyType $certificateTypes[$alg].KeyType -KeyLength $certificateTypes[$alg].KeyLength -CertificatePurpose CodeSigning -Ephemeral -Exportable + + Export-PodeCertificate -Certificate $x509Certificate -Format PFX -Path (join-path -path $CertsPath -ChildPath $alg) + + # Define the authentication location dynamically (e.g., `/auth/bearer/jwt/{algorithm}`) + $pathRoute = "/auth/bearer/jwt/$alg" + # Register Pode Bearer Authentication + Write-PodeHost "🔹 Registering JWT Authentication for: $alg ($Location)" + + $rsaPaddingScheme = if ($alg.StartsWith('PS')) { 'Pss' } else { 'Pkcs1V15' } + + $param = @{ + Location = $Location + AsJWT = $true + RsaPaddingScheme = $rsaPaddingScheme + JwtVerificationMode = $JwtVerificationMode + X509Certificate = $x509Certificate + } + + New-PodeAuthBearerScheme @param | + Add-PodeAuth -Name "Bearer_JWT_$alg" -Sessionless -ScriptBlock { + param($jwt) + + # here you'd check a real user storage, this is just for example + if ($jwt.username -ieq 'morty') { + return @{ + User = @{ + ID = $jWt.id + Name = $jst.name + Type = $jst.type + } + } + } + + return $null + } + + # GET request to get list of users (since there's no session, authentication will always happen) + Add-PodeRoute -Method Get -Path $pathRoute -Authentication "Bearer_JWT_$alg" -ScriptBlock { + + Write-PodeJsonResponse -Value @{ + Users = @( + @{ + Name = 'Deep Thought' + Age = 42 + }, + @{ + Name = 'Leeroy Jenkins' + Age = 1337 + } + ) + } + } + } + + + + # setup bearer auth + New-PodeAuthBearerScheme -Location $Location -AsJWT -Secret (ConvertTo-SecureString 'your-256-bit-secret' -AsPlainText -Force) -JwtVerificationMode Lenient | Add-PodeAuth -Name 'Validate' -Sessionless -ScriptBlock { + param($jwt) + + # here you'd check a real user storage, this is just for example + if ($jwt.username -ieq 'morty') { + return @{ + User = @{ + ID = $jWt.id + Name = $jst.name + Type = $jst.type + } + } + } + + 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 { + + Write-PodeJsonResponse -Value @{ + Users = @( + @{ + Name = 'Deep Thought' + Age = 42 + }, + @{ + Name = 'Leeroy Jenkins' + Age = 1337 + } + ) + } + } + + + Register-PodeEvent -Type Stop -Name 'CleanCerts' -ScriptBlock { + if ( (Test-Path -Path "$(Get-PodeServerPath)/cert" -PathType Container)) { + Remove-Item -Path "$(Get-PodeServerPath)/cert" -Recurse -Force + Write-PodeHost "$(Get-PodeServerPath)/cert removed." + } + } +} \ No newline at end of file diff --git a/examples/Authentication/Web-AuthBearerJWTLifecycle.ps1 b/examples/Authentication/Web-AuthBearerJWTLifecycle.ps1 new file mode 100644 index 000000000..90fe58d93 --- /dev/null +++ b/examples/Authentication/Web-AuthBearerJWTLifecycle.ps1 @@ -0,0 +1,274 @@ +<# +.SYNOPSIS + A PowerShell script demonstrating the full lifecycle of JWT authentication using X.509 certificates in Pode. + +.DESCRIPTION + This script sets up a Pode server that listens on a specified port and enables JWT authentication using X.509 certificates. + It showcases the full JWT authentication lifecycle, including login, renewal, validation, and retrieval of user information. + Authentication is performed using JWT tokens signed with the selected cryptographic algorithm. + +.PARAMETER Location + Specifies where the API key (JWT token) is expected. + Valid values: 'Header', 'Query'. + Default: 'Header'. + +.PARAMETER Algorithm + Specifies the cryptographic algorithm used for JWT signing and verification. + Valid values: 'RS256', 'RS384', 'RS512', 'PS256', 'PS384', 'PS512', 'ES256', 'ES384', 'ES512'. + Default: 'ES512'. + +.EXAMPLE + # Run the sample + ./WebAuth-bearerJWT.ps1 + + JWT payload example: + { + "sub": "1234567890", + "name": "morty", + "username": "morty", + "type": "Human", + "id": "M0R7Y302", + "admin": true, + "iat": 1516239022, + "exp": 2634234231, + "iss": "auth.example.com", + "aud": "myapi.example.com", + "nbf": 1690000000, + "jti": "unique-token-id", + "role": "admin" + } + +.LINK + https://github.com/Badgerati/Pode/blob/develop/examples/Authentication/Web-AuthbearerJWTLifecycle.ps1 + +.NOTES + - This script uses Pode to create a lightweight web server with authentication. + - JWT authentication is handled via Bearer tokens passed in either the header or query parameters. + - JWTs are signed using X.509 certificates and verified based on the selected algorithm. + - The script implements endpoints for login, token renewal, and token validation. + - Ensure the private key is securely stored and managed for RS256-based JWT signing. + - Using query parameters for authentication is **discouraged** due to security risks. + - Always use HTTPS in production to protect sensitive authentication data. + + Author: Pode Team + License: MIT License +#> + +param( + [Parameter()] + [ValidateSet('Header', 'Query' )] + [string] + $Location = 'Header', + + [Parameter()] + [ValidateSet( 'RS256', 'RS384', 'RS512', 'PS256', 'PS384', 'PS512', 'ES256', 'ES384', 'ES512')] + [string] + $Algorithm = 'ES512' +) + +try { + # Determine the script path and Pode module path + $ScriptPath = (Split-Path -Parent -Path (Split-Path -Parent -Path $MyInvocation.MyCommand.Path)) + $podePath = Split-Path -Parent -Path $ScriptPath + + # Import the Pode module from the source path if it exists, otherwise from installed modules + if (Test-Path -Path "$($podePath)/src/Pode.psm1" -PathType Leaf) { + Import-Module "$($podePath)/src/Pode.psm1" -Force -ErrorAction Stop + } + else { + Import-Module -Name 'Pode' -MaximumVersion 2.99 -ErrorAction Stop + } +} +catch { throw } + +# Define a function to autenticate user credentials +function Test-User { + param ( + [string]$username, + [string]$password + ) + if ($username -eq 'morty' -and $password -eq 'pickle') { + return @{ + Id = 'M0R7Y302' + Username = 'morty.smith' + Name = 'Morty Smith' + Groups = 'Domain Users' + } + } + throw 'Invalid credentials' +} + +# or just: +# Import-Module Pode + +# create a server, and start listening on port 8081 +Start-PodeServer -Threads 2 -ApplicationName 'webauth' { + + # listen on localhost:8081 + Add-PodeEndpoint -Address localhost -Port 8043 -Protocol Https -SelfSigned + + New-PodeLoggingMethod -File -Name 'requests' | Enable-PodeRequestLogging + New-PodeLoggingMethod -Terminal | Enable-PodeErrorLogging + # Configure CORS + Set-PodeSecurityAccessControl -Origin '*' -Duration 7200 -WithOptions -AuthorizationHeader -autoMethods -AutoHeader -Credentials -CrossDomainXhrRequests + + + # Enable OpenAPI documentation + + Enable-PodeOpenApi -Path '/docs/openapi' -OpenApiVersion '3.0.3' -EnableSchemaValidation:($PSVersionTable.PSEdition -eq 'Core') -DisableMinimalDefinitions -NoDefaultResponses + Add-PodeOAInfo -Title 'JWT Test' -Version 1.0.17 -Description 'test' + Add-PodeOAServerEndpoint -url '/auth/bearer/jwt' -Description 'default endpoint' + # Enable OpenAPI viewers + Enable-PodeOAViewer -Type Swagger -Path '/docs/swagger' + Enable-PodeOAViewer -Type ReDoc -Path '/docs/redoc' -DarkMode + Enable-PodeOAViewer -Type RapiDoc -Path '/docs/rapidoc' -DarkMode + Enable-PodeOAViewer -Type StopLight -Path '/docs/stoplight' -DarkMode + Enable-PodeOAViewer -Type Explorer -Path '/docs/explorer' -DarkMode + Enable-PodeOAViewer -Type RapiPdf -Path '/docs/rapipdf' -DarkMode + + # Enable OpenAPI editor and bookmarks + Enable-PodeOAViewer -Editor -Path '/docs/swagger-editor' + Enable-PodeOAViewer -Bookmarks -Path '/docs' + + + $JwtVerificationMode = 'Strict' # Set your desired verification mode (Lenient or Strict) + # $SecurePassword = ConvertTo-SecureString 'MySecurePassword' -AsPlainText -Force + + $param = @{ + Location = $Location + AsJWT = $true + JwtVerificationMode = $JwtVerificationMode + SelfSigned = $true + } + # Register Pode Bearer Authentication + New-PodeAuthBearerScheme @param | + Add-PodeAuth -Name "Bearer_JWT_$Algorithm" -Sessionless -ScriptBlock { + param($jwt) + + # here you'd check a real user storage, this is just for example + if ($jwt.id -ieq 'M0R7Y302') { + return @{ + User = @{ + ID = $jWt.id + Name = $jWt.name + Type = $jWt.type + sub = $jWt.Id + username = $jWt.Username + groups = $jWt.Groups + } + } + } + else { + write-podehost $jwt -Explode + } + + return $null + } + + # GET request to get list of users (since there's no session, authentication will always happen) + Add-PodeRoute -PassThru -Method Get -Path "/auth/bearer/jwt/$Algorithm" -Authentication "Bearer_JWT_$Algorithm" -ScriptBlock { + Write-PodeJsonResponse -Value $WebEvent.auth.User + } | Set-PodeOARouteInfo -Summary 'Get my info.' -Tags 'user' -OperationId "myinfo_$Algorithm" + + + + Add-PodeRoute -PassThru -Method Post -Path '/auth/bearer/jwt/login' -ArgumentList $Algorithm -ScriptBlock { + param( + [string] + $Algorithm + ) + try { + # In a real scenario, you'd validate the incoming credentials from $WebEvent.data + $username = $WebEvent.Data.username + $password = $WebEvent.Data.password + $user = Test-User -username $username -password $password + + + $payload = @{ + sub = $user.Id + name = $user.Name + username = $user.Username + id = $user.Id + groups = $user.Groups + type = 'human' + } + + # If valid, generate a JWT that matches the 'ExampleApiKeyCert' scheme + $jwt = ConvertTo-PodeJwt -Payload $payload -Authentication "Bearer_JWT_$Algorithm" -Expiration 600 + Write-PodeJsonResponse -StatusCode 200 -Value @{ + 'success' = $true + 'user' = $user + 'token' = $jwt + } + + } + catch { + write-podehost $_.Exception.Message + Write-PodeJsonResponse -StatusCode 401 -Value @{ error = 'Invalid credentials' } + } + } | Set-PodeOARouteInfo -Summary 'Logs user into the system.' -Tags 'user' -OperationId 'loginUser' -PassThru | + Set-PodeOARequest -RequestBody ( + New-PodeOARequestBody -Description 'Update an existent pet in the store' -Required -Content ( + New-PodeOAContentMediaType -ContentType 'application/json' -Content ( + New-PodeOAStringProperty -Name 'username' -Description 'The user name for login' -Default 'morty' | + New-PodeOAStringProperty -Name 'password' -Description 'The password for login in clear text' -Format Password -Default 'pickle' | + New-PodeOAObjectProperty) + ) + ) -PassThru | + Add-PodeOAResponse -StatusCode 200 -Description 'Successful operation' -Content ( + New-PodeOAContentMediaType -ContentType 'application/json' -Content ( + New-PodeOABoolProperty -Name 'success' -Description 'Operation success' -Example $true | + New-PodeOAStringProperty -Name 'user' -Description 'The user for login' -Example 'morty' | + New-PodeOAStringProperty -Name 'token' -Description 'Bearen JWT token' -Example '6656565' | + New-PodeOAObjectProperty + ) + ) -PassThru | + Add-PodeOAResponse -StatusCode 400 -Description 'Invalid username/password supplied' + + Add-PodeRoute -PassThru -Method Post -Path '/auth/bearer/jwt/renew' -Authentication "Bearer_JWT_$Algorithm" -ScriptBlock { + try { + + $jwt = Update-PodeJwt + + Write-PodeJsonResponse -StatusCode 200 -Value @{ + 'success' = $true + 'token' = $jwt + } + } + catch { + Write-PodeJsonResponse -StatusCode 401 -Value @{ error = 'Invalid JWT token supplied' } + } + } | Set-PodeOARouteInfo -Summary 'Extend JWT Token.' -Tags 'JWT' -OperationId 'renewToken' -PassThru | + Add-PodeOAResponse -StatusCode 200 -Description 'Successful operation' -Content ( + New-PodeOAContentMediaType -ContentType 'application/json' -Content ( + New-PodeOABoolProperty -Name 'success' -Description 'Operation success' -Example $true | + New-PodeOAStringProperty -Name 'user' -Description 'The user for login' -Example 'morty' | + New-PodeOAStringProperty -Name 'token' -Description 'Bearen JWT token' -Example 'eyJ0eXAiOiJKV1QifQ.eyJpZCI6Ik0wUjdZMzAyIi ... UG9kZSJ9.hhU1fmykkSyZhUCr1NSZto-dGyt50r5OUlYj5SgL88EFlnulSOtsM-61tht-X5lEZVP7TCwG2q6ZELiA-4zey7BTIEecKg8zQ4NasZQi6eq9scSL0WJPNHNiGf91F1BsSAQmTxmtJz9-R9l7dxxonFlgLhq9ZwToPuAEK76lYuEQ45ERH-LoO5En9nRnar5N8SLe244To_T7UPKKBgd_DQNSuW4pShMbeK1_TTwELxroV2-d7bPyhUKIwrP61DDsGxgYCzsJ_8XG4YOfFg_u3bHp_JEplCFPoc5KUVNOQHFCzYR0WMZDhRDMnAF6J8Xn0RKTsFB7q1QNC0NF1-7TGQ' | + New-PodeOAObjectProperty + ) + ) -PassThru | + Add-PodeOAResponse -StatusCode 401 -Description 'Invalid JWT token supplied' + + + Add-PodeRoute -PassThru -Method Get -Path '/auth/bearer/jwt/info' -Authentication "Bearer_JWT_$Algorithm" -ScriptBlock { + try { + $jwtInfo = ConvertFrom-PodeJwt -Outputs 'Header,Payload,Signature' -HumanReadable + $jwtInfo.success = $true + Write-PodeJsonResponse -StatusCode 200 -Value $jwtInfo + } + catch { + Write-PodeJsonResponse -StatusCode 401 -Value @{ error = 'Invalid JWT token supplied' } + } + } | Set-PodeOARouteInfo -Summary 'return JWT Token info.' -Tags 'JWT' -OperationId 'getInfoToken' -PassThru | + Add-PodeOAResponse -StatusCode 200 -Description 'Successful operation' -Content ( + New-PodeOAContentMediaType -ContentType 'application/json' -Content ( + New-PodeOAObjectProperty -Properties ( + ( New-PodeOABoolProperty -Name 'success' -Description 'Operation success' -Example $true), + (New-PodeOAObjectProperty -Name Header ), + ( New-PodeOAObjectProperty -Name Payload), ( New-PodeOAStringProperty -Name Signature) + ) + ) + ) -PassThru | + Add-PodeOAResponse -StatusCode 401 -Description 'Invalid JWT token supplied' + +} \ No newline at end of file diff --git a/examples/Authentication/Web-AuthDigest.ps1 b/examples/Authentication/Web-AuthDigest.ps1 new file mode 100644 index 000000000..779df40cc --- /dev/null +++ b/examples/Authentication/Web-AuthDigest.ps1 @@ -0,0 +1,215 @@ +<# +.SYNOPSIS + PowerShell script to set up a Pode server with Digest authentication or make client requests. + +.DESCRIPTION + This script can either: + - Start a Pode server that listens on a specified port and uses Digest authentication to secure access. + - Act as a client to send requests with Digest authentication. + + The authentication details are checked against predefined user data. + For non-MD5 algorithms, use ./utility/DigestClient.ps1. + +.PARAMETER Client + If specified, the script runs in client mode instead of starting a server. + +.PARAMETER Algorithm + The Digest authentication algorithm(s) to use. Supported values: MD5, SHA-1, SHA-256, SHA-512, SHA-384, SHA-512/256. + Defaults to all supported algorithms. + +.PARAMETER QualityOfProtection + Specifies the Quality of Protection (qop) to use in Digest authentication. + Valid options: + - 'auth': Authentication only. + - 'auth-int': Authentication with integrity protection. + - 'auth,auth-int': Support both modes. + +.EXAMPLE + To start the Pode server with default settings: + ```powershell + ./Web-AuthDigest.ps1 + ``` + +.EXAMPLE + To start the Pode server with SHA-256 authentication only: + ```powershell + ./Web-AuthDigest.ps1 -Algorithm SHA-256 + ``` + +.EXAMPLE + To run in client mode and send a Digest-authenticated request: + ```powershell + ./Web-AuthDigest.ps1 -Client + ``` + +.EXAMPLE + Client request example using default .Net Digest support: + + ```powershell + # 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 + $requestMessage = [System.Net.Http.HttpRequestMessage]::new([System.Net.Http.HttpMethod]::Get, $uri) + $response = $httpClient.SendAsync($requestMessage).Result + + # Display response headers and content + $response.Headers | ForEach-Object { "$($_.Key): $($_.Value)" } + $content = $response.Content.ReadAsStringAsync().Result + $content + ``` +.EXAMPLE + Client request example using `Invoke-WebRequestDigest`: + + ```powershell + Import-Module './client/Invoke-Digest.psm1' + + # Define the URI and credentials + $uri = 'http://localhost:8081/users' + $username = 'morty' + $password = 'pickle' + + # Convert the password to a SecureString and create a credential object + $securePassword = ConvertTo-SecureString $password -AsPlainText -Force + $credential = [System.Management.Automation.PSCredential]::new($username, $securePassword) + + # Make a GET request using Digest authentication + $response = Invoke-WebRequestDigest -Uri $uri -Method 'GET' -Credential $credential + + # Display response headers and content + $response.Headers | Format-List + Write-Output $response.Content + ``` + +.EXAMPLE + Running the server with `auth-int` quality of protection: + ```powershell + ./Web-AuthDigest.ps1 -QualityOfProtection auth-int + ``` + +.LINK + https://github.com/Badgerati/Pode/blob/develop/examples/Authentication/Web-AuthDigest.ps1 + +.NOTES + Author: Pode Team + License: MIT License +#> + +[CmdletBinding(DefaultParameterSetName = 'Server')] +param( + [Parameter(ParameterSetName = 'Client')] + [switch] + $Client, + + [Parameter(ParameterSetName = 'Server')] + [string[]] + $Algorithm = @('MD5', 'SHA-1', 'SHA-256', 'SHA-512', 'SHA-384', 'SHA-512/256'), + + [Parameter(ParameterSetName = 'Server')] + [ValidateSet('auth', 'auth-int', 'auth,auth-int' )] + [string[]] + $QualityOfProtection = 'auth,auth-int' +) +if ($Client) { + Import-Module './Modules/Invoke-Digest.psm1' + $uri = 'http://localhost:8081/users' + $username = 'morty' + $password = 'pickle' + + $securePassword = ConvertTo-SecureString $password -AsPlainText -Force + $credential = [System.Management.Automation.PSCredential]::new($username, $securePassword) + + $response = Invoke-WebRequestDigest -Uri $uri -Method 'GET' -Credential $credential + $response | Format-List * + + Invoke-WebRequestDigest -Uri $uri -Method 'GET' -Credential $credential -OutFile 'outfile.json' + + $response = Invoke-RestMethodDigest -Uri $uri -Method 'GET' -Credential $credential + $response + return +} +try { + # Determine the script path and Pode module path + $ScriptPath = (Split-Path -Parent -Path (Split-Path -Parent -Path $MyInvocation.MyCommand.Path)) + $podePath = Split-Path -Parent -Path $ScriptPath + + # Import the Pode module from the source path if it exists, otherwise from installed modules + if (Test-Path -Path "$($podePath)/src/Pode.psm1" -PathType Leaf) { + Import-Module "$($podePath)/src/Pode.psm1" -Force -ErrorAction Stop + } + else { + Import-Module -Name 'Pode' -MaximumVersion 2.99 -ErrorAction Stop + } +} +catch { throw } + +# or just: +# Import-Module Pode + +# create a server, and start listening on port 8081 +Start-PodeServer -Threads 2 { + + # listen on localhost:8081 + Add-PodeEndpoint -Address localhost -Port 8081 -Protocol Http + + # setup digest auth + New-PodeAuthDigestScheme -Algorithm $Algorithm -QualityOfProtection $QualityOfProtection | Add-PodeAuth -Name 'Validate' -Sessionless -ScriptBlock { + param($username, $params) + + # here you'd check a real user storage, this is just for example + if ($username -ieq 'morty') { + return @{ + User = @{ + ID = 'M0R7Y302' + Name = 'Morty' + Type = 'Human' + } + Password = 'pickle' + } + } + + return $null + } + # If QualityOfProtection is 'auth-int' skip GET because it is not supported + if ($QualityOfProtection -ne 'auth-int') { + # GET request to get list of users (since there's no session, authentication will always happen) + Add-PodeRoute -Method Get -Path '/users' -Authentication 'Validate' -ErrorContentType 'application/json' -ScriptBlock { + Write-PodeJsonResponse -Value @{ + Users = @( + @{ + Name = 'Deep Thought' + Age = 42 + }, + @{ + Name = 'Leeroy Jenkins' + Age = 1337 + } + ) + } + } + } + + Add-PodeRoute -Method Post -Path '/users' -Authentication 'Validate' -ErrorContentType 'application/json' -ScriptBlock { + if ($WebEvent.data) { + Write-PodeJsonResponse -Value $WebEvent.data -StatusCode 200 + } + else { + Write-PodeJsonResponse -Value @{success = $false } -StatusCode 400 + } + } + +} \ No newline at end of file diff --git a/examples/Web-AuthForm.ps1 b/examples/Authentication/Web-AuthForm.ps1 similarity index 92% rename from examples/Web-AuthForm.ps1 rename to examples/Authentication/Web-AuthForm.ps1 index 45fad7a19..095f6539d 100644 --- a/examples/Web-AuthForm.ps1 +++ b/examples/Authentication/Web-AuthForm.ps1 @@ -22,7 +22,7 @@ You will be redirected to the login page, where you can log in with the credentials provided above. .LINK - https://github.com/Badgerati/Pode/blob/develop/examples/Web-AuthForm.ps1 + https://github.com/Badgerati/Pode/blob/develop/examples/Authentication/Web-AuthForm.ps1 .NOTES Author: Pode Team @@ -31,7 +31,7 @@ try { # Determine the script path and Pode module path - $ScriptPath = (Split-Path -Parent -Path $MyInvocation.MyCommand.Path) + $ScriptPath = (Split-Path -Parent -Path (Split-Path -Parent -Path $MyInvocation.MyCommand.Path)) $podePath = Split-Path -Parent -Path $ScriptPath # Import the Pode module from the source path if it exists, otherwise from installed modules @@ -63,7 +63,7 @@ Start-PodeServer -Threads 2 { Enable-PodeSessionMiddleware -Duration 120 -Extend # setup form auth (
in HTML) - New-PodeAuthScheme -Form | Add-PodeAuth -Name 'Login' -FailureUrl '/login' -SuccessUrl '/' -ScriptBlock { + New-PodeAuthScheme -Form | Add-PodeAuth -Name 'Login' -FailureUrl '/login' -SuccessUrl '/' -ScriptBlock { param($username, $password) # here you'd check a real user storage, this is just for example diff --git a/examples/Web-AuthFormAccess.ps1 b/examples/Authentication/Web-AuthFormAccess.ps1 similarity index 95% rename from examples/Web-AuthFormAccess.ps1 rename to examples/Authentication/Web-AuthFormAccess.ps1 index 3bf19c479..af5e82327 100644 --- a/examples/Web-AuthFormAccess.ps1 +++ b/examples/Authentication/Web-AuthFormAccess.ps1 @@ -23,7 +23,7 @@ - The Register page is only accessible by QAs (for morty this will 403) .LINK - https://github.com/Badgerati/Pode/blob/develop/examples/Web-AuthFormAccess.ps1 + https://github.com/Badgerati/Pode/blob/develop/examples/Authentication/Web-AuthFormAccess.ps1 .NOTES Author: Pode Team @@ -31,7 +31,7 @@ #> try { # Determine the script path and Pode module path - $ScriptPath = (Split-Path -Parent -Path $MyInvocation.MyCommand.Path) + $ScriptPath = (Split-Path -Parent -Path (Split-Path -Parent -Path $MyInvocation.MyCommand.Path)) $podePath = Split-Path -Parent -Path $ScriptPath # Import the Pode module from the source path if it exists, otherwise from installed modules diff --git a/examples/Web-AuthFormAd.ps1 b/examples/Authentication/Web-AuthFormAd.ps1 similarity index 94% rename from examples/Web-AuthFormAd.ps1 rename to examples/Authentication/Web-AuthFormAd.ps1 index 78bf5eedd..bc87990dd 100644 --- a/examples/Web-AuthFormAd.ps1 +++ b/examples/Authentication/Web-AuthFormAd.ps1 @@ -19,7 +19,7 @@ the login page. .LINK - https://github.com/Badgerati/Pode/blob/develop/examples/Web-AuthFormAd.ps1 + https://github.com/Badgerati/Pode/blob/develop/examples/Authentication/Web-AuthFormAd.ps1 .NOTES Author: Pode Team @@ -27,7 +27,7 @@ #> try { # Determine the script path and Pode module path - $ScriptPath = (Split-Path -Parent -Path $MyInvocation.MyCommand.Path) + $ScriptPath = (Split-Path -Parent -Path (Split-Path -Parent -Path $MyInvocation.MyCommand.Path)) $podePath = Split-Path -Parent -Path $ScriptPath # Import the Pode module from the source path if it exists, otherwise from installed modules diff --git a/examples/Web-AuthFormAnon.ps1 b/examples/Authentication/Web-AuthFormAnon.ps1 similarity index 95% rename from examples/Web-AuthFormAnon.ps1 rename to examples/Authentication/Web-AuthFormAnon.ps1 index 3860d5ed1..488734389 100644 --- a/examples/Web-AuthFormAnon.ps1 +++ b/examples/Authentication/Web-AuthFormAnon.ps1 @@ -24,7 +24,7 @@ Invoke-RestMethod -Uri http://localhost:8081/ -Method Get .LINK - https://github.com/Badgerati/Pode/blob/develop/examples/Web-AuthFormAnon.ps1 + https://github.com/Badgerati/Pode/blob/develop/examples/Authentication/Web-AuthFormAnon.ps1 .NOTES Author: Pode Team @@ -32,7 +32,7 @@ #> try { # Determine the script path and Pode module path - $ScriptPath = (Split-Path -Parent -Path $MyInvocation.MyCommand.Path) + $ScriptPath = (Split-Path -Parent -Path (Split-Path -Parent -Path $MyInvocation.MyCommand.Path)) $podePath = Split-Path -Parent -Path $ScriptPath # Import the Pode module from the source path if it exists, otherwise from installed modules diff --git a/examples/Web-AuthFormCreds.ps1 b/examples/Authentication/Web-AuthFormCreds.ps1 similarity index 95% rename from examples/Web-AuthFormCreds.ps1 rename to examples/Authentication/Web-AuthFormCreds.ps1 index 282d7a2bc..ba1eb2f8f 100644 --- a/examples/Web-AuthFormCreds.ps1 +++ b/examples/Authentication/Web-AuthFormCreds.ps1 @@ -23,7 +23,7 @@ #logout url .LINK - https://github.com/Badgerati/Pode/blob/develop/examples/Web-AuthFormCreds.ps1 + https://github.com/Badgerati/Pode/blob/develop/examples/Authentication/Web-AuthFormCreds.ps1 .NOTES Author: Pode Team @@ -32,7 +32,7 @@ try { # Determine the script path and Pode module path - $ScriptPath = (Split-Path -Parent -Path $MyInvocation.MyCommand.Path) + $ScriptPath = (Split-Path -Parent -Path (Split-Path -Parent -Path $MyInvocation.MyCommand.Path)) $podePath = Split-Path -Parent -Path $ScriptPath # Import the Pode module from the source path if it exists, otherwise from installed modules diff --git a/examples/Web-AuthFormFile.ps1 b/examples/Authentication/Web-AuthFormFile.ps1 similarity index 94% rename from examples/Web-AuthFormFile.ps1 rename to examples/Authentication/Web-AuthFormFile.ps1 index 70f55c320..dac8ce0dc 100644 --- a/examples/Web-AuthFormFile.ps1 +++ b/examples/Authentication/Web-AuthFormFile.ps1 @@ -21,7 +21,7 @@ password = pickle .LINK - https://github.com/Badgerati/Pode/blob/develop/examples/Web-AuthFormFile.ps1 + https://github.com/Badgerati/Pode/blob/develop/examples/Authentication/Web-AuthFormFile.ps1 .NOTES Author: Pode Team @@ -30,7 +30,7 @@ try { # Determine the script path and Pode module path - $ScriptPath = (Split-Path -Parent -Path $MyInvocation.MyCommand.Path) + $ScriptPath = (Split-Path -Parent -Path (Split-Path -Parent -Path $MyInvocation.MyCommand.Path)) $podePath = Split-Path -Parent -Path $ScriptPath # Import the Pode module from the source path if it exists, otherwise from installed modules diff --git a/examples/Web-AuthFormLocal.ps1 b/examples/Authentication/Web-AuthFormLocal.ps1 similarity index 94% rename from examples/Web-AuthFormLocal.ps1 rename to examples/Authentication/Web-AuthFormLocal.ps1 index 3c55cafbd..9d2ef3373 100644 --- a/examples/Web-AuthFormLocal.ps1 +++ b/examples/Authentication/Web-AuthFormLocal.ps1 @@ -17,7 +17,7 @@ Clicking 'Logout' will purge the session and take you back to the login page. .LINK - https://github.com/Badgerati/Pode/blob/develop/examples/Web-AuthFormLocal.ps1 + https://github.com/Badgerati/Pode/blob/develop/examples/Authentication/Web-AuthFormLocal.ps1 .NOTES Author: Pode Team @@ -25,7 +25,7 @@ #> try { # Determine the script path and Pode module path - $ScriptPath = (Split-Path -Parent -Path $MyInvocation.MyCommand.Path) + $ScriptPath = (Split-Path -Parent -Path (Split-Path -Parent -Path $MyInvocation.MyCommand.Path)) $podePath = Split-Path -Parent -Path $ScriptPath # Import the Pode module from the source path if it exists, otherwise from installed modules diff --git a/examples/Web-AuthFormMerged.ps1 b/examples/Authentication/Web-AuthFormMerged.ps1 similarity index 95% rename from examples/Web-AuthFormMerged.ps1 rename to examples/Authentication/Web-AuthFormMerged.ps1 index 8345b7101..11f03d2b3 100644 --- a/examples/Web-AuthFormMerged.ps1 +++ b/examples/Authentication/Web-AuthFormMerged.ps1 @@ -18,7 +18,7 @@ take you back to the login page. .LINK - https://github.com/Badgerati/Pode/blob/develop/examples/Web-AuthFormMerged.ps1 + https://github.com/Badgerati/Pode/blob/develop/examples/Authentication/Web-AuthFormMerged.ps1 .NOTES Author: Pode Team @@ -26,7 +26,7 @@ #> try { # Determine the script path and Pode module path - $ScriptPath = (Split-Path -Parent -Path $MyInvocation.MyCommand.Path) + $ScriptPath = (Split-Path -Parent -Path (Split-Path -Parent -Path $MyInvocation.MyCommand.Path)) $podePath = Split-Path -Parent -Path $ScriptPath # Import the Pode module from the source path if it exists, otherwise from installed modules diff --git a/examples/Web-AuthFormSessionAuth.ps1 b/examples/Authentication/Web-AuthFormSessionAuth.ps1 similarity index 95% rename from examples/Web-AuthFormSessionAuth.ps1 rename to examples/Authentication/Web-AuthFormSessionAuth.ps1 index 6545ad9bb..440939700 100644 --- a/examples/Web-AuthFormSessionAuth.ps1 +++ b/examples/Authentication/Web-AuthFormSessionAuth.ps1 @@ -19,7 +19,7 @@ take you back to the login page. .LINK - https://github.com/Badgerati/Pode/blob/develop/examples/Web-AuthFormSessionAuth.ps1 + https://github.com/Badgerati/Pode/blob/develop/examples/Authentication/Web-AuthFormSessionAuth.ps1 .NOTES Author: Pode Team @@ -28,7 +28,7 @@ try { # Determine the script path and Pode module path - $ScriptPath = (Split-Path -Parent -Path $MyInvocation.MyCommand.Path) + $ScriptPath = (Split-Path -Parent -Path (Split-Path -Parent -Path $MyInvocation.MyCommand.Path)) $podePath = Split-Path -Parent -Path $ScriptPath # Import the Pode module from the source path if it exists, otherwise from installed modules diff --git a/examples/Web-AuthMerged.ps1 b/examples/Authentication/Web-AuthMerged.ps1 similarity index 95% rename from examples/Web-AuthMerged.ps1 rename to examples/Authentication/Web-AuthMerged.ps1 index c349487c3..1d7b51651 100644 --- a/examples/Web-AuthMerged.ps1 +++ b/examples/Authentication/Web-AuthMerged.ps1 @@ -17,15 +17,15 @@ Invoke-RestMethod -Method Get -Uri 'http://localhost:8081/users' -Headers @{ 'X-API-KEY' = 'test-api-key'; Authorization = 'Basic bW9ydHk6cmljaw==' .LINK - https://github.com/Badgerati/Pode/blob/develop/examples/Web-AuthMerged.ps1 - + https://github.com/Badgerati/Pode/blob/develop/examples/Authentication/Web-AuthMerged.ps1 + .NOTES Author: Pode Team License: MIT License #> try { # Determine the script path and Pode module path - $ScriptPath = (Split-Path -Parent -Path $MyInvocation.MyCommand.Path) + $ScriptPath = (Split-Path -Parent -Path (Split-Path -Parent -Path $MyInvocation.MyCommand.Path)) $podePath = Split-Path -Parent -Path $ScriptPath # Import the Pode module from the source path if it exists, otherwise from installed modules diff --git a/examples/Web-AuthNegotiate.ps1 b/examples/Authentication/Web-AuthNegotiate.ps1 similarity index 89% rename from examples/Web-AuthNegotiate.ps1 rename to examples/Authentication/Web-AuthNegotiate.ps1 index d63a9ba6c..1e1361c56 100644 --- a/examples/Web-AuthNegotiate.ps1 +++ b/examples/Authentication/Web-AuthNegotiate.ps1 @@ -13,7 +13,7 @@ Invoke-RestMethod -Uri 'http://pode.example.com:8080' -UseDefaultCredentials .LINK - https://github.com/Badgerati/Pode/blob/develop/examples/Web-AuthNegotiate.ps1 + https://github.com/Badgerati/Pode/blob/develop/examples/Authentication/Web-AuthNegotiate.ps1 .NOTES Author: Pode Team @@ -22,7 +22,7 @@ try { # Determine the script path and Pode module path - $ScriptPath = (Split-Path -Parent -Path $MyInvocation.MyCommand.Path) + $ScriptPath = (Split-Path -Parent -Path (Split-Path -Parent -Path $MyInvocation.MyCommand.Path)) $podePath = Split-Path -Parent -Path $ScriptPath # Import the Pode module from the source path if it exists, otherwise from installed modules diff --git a/examples/Web-AuthOauth2.ps1 b/examples/Authentication/Web-AuthOauth2.ps1 similarity index 93% rename from examples/Web-AuthOauth2.ps1 rename to examples/Authentication/Web-AuthOauth2.ps1 index befdf7f68..9ab1001c3 100644 --- a/examples/Web-AuthOauth2.ps1 +++ b/examples/Authentication/Web-AuthOauth2.ps1 @@ -13,7 +13,7 @@ There, login to Azure and you'll be redirected back to the home page .LINK - https://github.com/Badgerati/Pode/blob/develop/examples/Web-AuthOauth2.ps1 + https://github.com/Badgerati/Pode/blob/develop/examples/Authentication/Web-AuthOauth2.ps1 .NOTES Author: Pode Team @@ -23,7 +23,7 @@ #> try { # Determine the script path and Pode module path - $ScriptPath = (Split-Path -Parent -Path $MyInvocation.MyCommand.Path) + $ScriptPath = (Split-Path -Parent -Path (Split-Path -Parent -Path $MyInvocation.MyCommand.Path)) $podePath = Split-Path -Parent -Path $ScriptPath # Import the Pode module from the source path if it exists, otherwise from installed modules diff --git a/examples/Web-AuthOauth2Form.ps1 b/examples/Authentication/Web-AuthOauth2Form.ps1 similarity index 94% rename from examples/Web-AuthOauth2Form.ps1 rename to examples/Authentication/Web-AuthOauth2Form.ps1 index a2f546c3a..5b79a8317 100644 --- a/examples/Web-AuthOauth2Form.ps1 +++ b/examples/Authentication/Web-AuthOauth2Form.ps1 @@ -13,7 +13,7 @@ There, enter you Azure AD email/password, Pode with authenticate and then take you to the home page .LINK - https://github.com/Badgerati/Pode/blob/develop/examples/Web-AuthFormCreds.ps1 + https://github.com/Badgerati/Pode/blob/develop/examples/Authentication/Web-AuthFormCreds.ps1 .NOTES Author: Pode Team @@ -23,7 +23,7 @@ #> try { # Determine the script path and Pode module path - $ScriptPath = (Split-Path -Parent -Path $MyInvocation.MyCommand.Path) + $ScriptPath = (Split-Path -Parent -Path (Split-Path -Parent -Path $MyInvocation.MyCommand.Path)) $podePath = Split-Path -Parent -Path $ScriptPath # Import the Pode module from the source path if it exists, otherwise from installed modules diff --git a/examples/Web-AuthOauth2Oidc.ps1 b/examples/Authentication/Web-AuthOauth2Oidc.ps1 similarity index 93% rename from examples/Web-AuthOauth2Oidc.ps1 rename to examples/Authentication/Web-AuthOauth2Oidc.ps1 index c090be8da..51f62f7c6 100644 --- a/examples/Web-AuthOauth2Oidc.ps1 +++ b/examples/Authentication/Web-AuthOauth2Oidc.ps1 @@ -13,8 +13,8 @@ There, login to Google account and you'll be redirected back to the home page .LINK - https://github.com/Badgerati/Pode/blob/develop/examples/Web-AuthOauth2Oidc.ps1 - + https://github.com/Badgerati/Pode/blob/develop/examples/Authentication/Web-AuthOauth2Oidc.ps1 + .NOTES Author: Pode Team License: MIT License @@ -23,7 +23,7 @@ #> try { # Determine the script path and Pode module path - $ScriptPath = (Split-Path -Parent -Path $MyInvocation.MyCommand.Path) + $ScriptPath = (Split-Path -Parent -Path (Split-Path -Parent -Path $MyInvocation.MyCommand.Path)) $podePath = Split-Path -Parent -Path $ScriptPath # Import the Pode module from the source path if it exists, otherwise from installed modules diff --git a/examples/Web-UsePodeAuth.ps1 b/examples/Authentication/Web-UsePodeAuth.ps1 similarity index 93% rename from examples/Web-UsePodeAuth.ps1 rename to examples/Authentication/Web-UsePodeAuth.ps1 index d72c8ff20..bfcee91f8 100644 --- a/examples/Web-UsePodeAuth.ps1 +++ b/examples/Authentication/Web-UsePodeAuth.ps1 @@ -25,7 +25,7 @@ ``` .LINK - https://github.com/Badgerati/Pode/blob/develop/examples/Web-UsePodeAuth.ps1 + https://github.com/Badgerati/Pode/blob/develop/examples/Authentication/Web-UsePodeAuth.ps1 .NOTES The `Use-PodeAuth` function is used to load the authentication script located at `./auth/SampleAuth.ps1`. The @@ -45,7 +45,7 @@ param( try { # Determine the script path and Pode module path - $ScriptPath = (Split-Path -Parent -Path $MyInvocation.MyCommand.Path) + $ScriptPath = (Split-Path -Parent -Path (Split-Path -Parent -Path $MyInvocation.MyCommand.Path)) $podePath = Split-Path -Parent -Path $ScriptPath # Import the Pode module from the source path if it exists, otherwise from installed modules diff --git a/examples/Web-AuthApiKey.ps1 b/examples/Authentication/WebAuth-ApikeyJWT.ps1 similarity index 93% rename from examples/Web-AuthApiKey.ps1 rename to examples/Authentication/WebAuth-ApikeyJWT.ps1 index 6addc1971..025efdf9e 100644 --- a/examples/Web-AuthApiKey.ps1 +++ b/examples/Authentication/WebAuth-ApikeyJWT.ps1 @@ -15,7 +15,7 @@ Invoke-RestMethod -Uri http://localhost:8081/users -Method Get .LINK - https://github.com/Badgerati/Pode/blob/develop/examples/Web-AuthApiKey.ps1 + https://github.com/Badgerati/Pode/blob/develop/examples/Authentication/Web-AuthApiKey.ps1 .NOTES Use: @@ -34,7 +34,7 @@ param( try { # Determine the script path and Pode module path - $ScriptPath = (Split-Path -Parent -Path $MyInvocation.MyCommand.Path) + $ScriptPath = (Split-Path -Parent -Path (Split-Path -Parent -Path $MyInvocation.MyCommand.Path)) $podePath = Split-Path -Parent -Path $ScriptPath # Import the Pode module from the source path if it exists, otherwise from installed modules diff --git a/examples/auth/SampleAuth.ps1 b/examples/Authentication/auth/SampleAuth.ps1 similarity index 100% rename from examples/auth/SampleAuth.ps1 rename to examples/Authentication/auth/SampleAuth.ps1 diff --git a/examples/Authentication/client/New-JwtKeyPair.ps1 b/examples/Authentication/client/New-JwtKeyPair.ps1 new file mode 100644 index 000000000..6dc27f843 --- /dev/null +++ b/examples/Authentication/client/New-JwtKeyPair.ps1 @@ -0,0 +1,150 @@ +<# +.SYNOPSIS + Generates JWT key pairs for testing and example purposes. + +.DESCRIPTION + This utility generates RSA and ECDSA key pairs based on the specified mode: + - "Test" mode: Keys are created under "./tests/certs" + - "Example" mode: Keys are created under "./examples/certs" + +.PARAMETER Mode + Specifies the mode of key generation. Accepts "Test" or "Example". + +.PARAMETER Algorithm + Specifies the algorithms to generate keys for. Accepts an array of values (e.g., "RS256", "ES256") or "ALL". + +.OUTPUTS + PEM-encoded private and public key files. + +.EXAMPLE + # Generate all keys for testing + .\New-JwtKeyPair.ps1 -Mode Test + +.EXAMPLE + # Generate only RS256 and ES256 keys for examples + .\New-JwtKeyPair.ps1 -Mode Example -Algorithm RS256,ES256 + +.LINK + https://github.com/Badgerati/Pode/blob/develop/examples/utilities/New-JwtKeyPair.ps1 + +.NOTES + - Keys are stored in the respective directories: "./tests/certs" or "./examples/certs" + - Requires PowerShell 7+ + +.NOTES + Author: Pode Team + License: MIT License +#> + +param ( + [Parameter(Mandatory = $true)] + [ValidateSet('Test', 'Example')] + [string]$Mode, + + [string[]]$Algorithm = @('ALL') +) + + + +### Helper Functions for Key Export ### +function Export-RsaPrivateKeyPem { + param ( + [System.Security.Cryptography.RSA]$RsaKey + ) + $pemHeader = '-----BEGIN RSA PRIVATE KEY-----' + $pemFooter = '-----END RSA PRIVATE KEY-----' + $base64 = [Convert]::ToBase64String($RsaKey.ExportRSAPrivateKey(), 'InsertLineBreaks') + return "$pemHeader`n$base64`n$pemFooter" +} + + +function Export-RsaPublicKeyPem { + param ([System.Security.Cryptography.RSA]$RsaKey) + $pemHeader = '-----BEGIN RSA PUBLIC KEY-----' + $pemFooter = '-----END RSA PUBLIC KEY-----' + $base64 = [Convert]::ToBase64String($RsaKey.ExportRSAPublicKey(), 'InsertLineBreaks') + return "$pemHeader`n$base64`n$pemFooter" +} + +function Export-EcdsaPrivateKeyPem { + param ([System.Security.Cryptography.ECDsa]$EcdsaKey) + $pemHeader = '-----BEGIN EC PRIVATE KEY-----' + $pemFooter = '-----END EC PRIVATE KEY-----' + $base64 = [Convert]::ToBase64String($EcdsaKey.ExportECPrivateKey(), 'InsertLineBreaks') + return "$pemHeader`n$base64`n$pemFooter" +} + +function Export-EcdsaPublicKeyPem { + param ([System.Security.Cryptography.ECDsa]$EcdsaKey) + $pemHeader = '-----BEGIN PUBLIC KEY-----' + $pemFooter = '-----END PUBLIC KEY-----' + $base64 = [Convert]::ToBase64String($EcdsaKey.ExportSubjectPublicKeyInfo(), 'InsertLineBreaks') + return "$pemHeader`n$base64`n$pemFooter" +} + +# Determine output directory based on mode +$RootPath = Split-Path -Parent $MyInvocation.MyCommand.Path +$BaseOutputDirectory = if ($Mode -eq 'Test') { "$RootPath/../../tests/certs" } else { "$RootPath/../../examples/certs" } + +if (Test-Path -Path $BaseOutputDirectory) { + Remove-Item -Path "$BaseOutputDirectory/*.pem" +} +else { + New-Item -Path $BaseOutputDirectory -ItemType Directory +} + +# Key settings mapping +$keySettings = @{ + 'RS256' = 2048 + 'RS384' = 3072 + 'RS512' = 4096 + 'ES256' = [System.Security.Cryptography.ECCurve]::CreateFromFriendlyName('nistP256') + 'ES384' = [System.Security.Cryptography.ECCurve]::CreateFromFriendlyName('nistP384') + 'ES512' = [System.Security.Cryptography.ECCurve]::CreateFromFriendlyName('nistP521') +} + +# Ensure output directory exists +if (-Not (Test-Path $BaseOutputDirectory)) { + New-Item -ItemType Directory -Path $BaseOutputDirectory -Force | Out-Null +} + +# Determine algorithms to generate +$algorithmsToGenerate = if ($Algorithm -contains 'ALL') { $keySettings.Keys } else { $Algorithm } + +foreach ($alg in $algorithmsToGenerate) { + if (-Not $keySettings.ContainsKey($alg)) { + Write-Output "❌ Unsupported algorithm: $alg. Skipping..." + Continue + } + + $privateKeyPath = "$BaseOutputDirectory/$alg-private.pem" + $publicKeyPath = "$BaseOutputDirectory/$alg-public.pem" + + Write-Output "🔹 Generating keys for: $alg..." + + if ($alg -match '^RS') { + $rsa = [System.Security.Cryptography.RSA]::Create($keySettings[$alg]) + + $privatePem = Export-RsaPrivateKeyPem $rsa + Set-Content -Path $privateKeyPath -Value $privatePem + + $publicPem = Export-RsaPublicKeyPem $rsa + Set-Content -Path $publicKeyPath -Value $publicPem + } + elseif ($alg -match '^ES') { + $ec = [System.Security.Cryptography.ECDsa]::Create($keySettings[$alg]) + if ($null -eq $ec) { + throw "Failed to create ECDSA key for $alg. Ensure your system supports ECC." + } + + $privatePem = Export-EcdsaPrivateKeyPem $ec + Set-Content -Path $privateKeyPath -Value $privatePem + + $publicPem = Export-EcdsaPublicKeyPem $ec + Set-Content -Path $publicKeyPath -Value $publicPem + } + + Write-Output "✅ Keys generated: $privateKeyPath & $publicKeyPath" +} + +Write-Output "🎉 All requested keys generated successfully in: $BaseOutputDirectory" diff --git a/examples/Authentication/client/Test-BearerClient.ps1 b/examples/Authentication/client/Test-BearerClient.ps1 new file mode 100644 index 000000000..57c4ff76a --- /dev/null +++ b/examples/Authentication/client/Test-BearerClient.ps1 @@ -0,0 +1,146 @@ +<# +.SYNOPSIS + PowerShell script to test JWT authentication against a Pode server. + +.DESCRIPTION + This script performs authentication tests against a Pode server using JWT bearer tokens. + It iterates over multiple JWT signing algorithms, generates tokens, and sends authenticated + requests to verify the implementation. + + - Supports RSA (`RS256`, `RS384`, `RS512`), PSS (`PS256`, `PS384`, `PS512`), and EC (`ES256`, `ES384`, `ES512`) algorithms. + - Checks for the availability of private keys before attempting authentication. + - Uses `ConvertTo-PodeJwt` for JWT generation. + - Sends requests to the Pode authentication API and validates responses. + +.PARAMETER ApiBaseUrl + The base URL of the Pode authentication endpoint. + +.EXAMPLE + # Run the script to test JWT authentication + ./Test-BearerClient.ps1 + +.EXAMPLE + # Manually specify the authentication API URL + $uri = "http://localhost:8081/auth/bearer/jwt" + ./Test-BearerClient.ps1 -ApiBaseUrl $uri + + .LINK + https://github.com/Badgerati/Pode/blob/develop/examples/utilities/Test-BearerClient.ps1 + +.NOTES + - **JWT Authentication Overview:** + - The script loads private keys for multiple algorithms. + - It generates JWTs using `ConvertTo-PodeJwt` with a test payload. + - Each JWT is used to authenticate a request against the Pode API. + - Responses are validated and displayed in JSON format. + + - **Pode Compatibility:** + - Pode supports various JWT signing algorithms. + - Ensure Pode is configured with `New-PodeAuthScheme -BearerJwt` for JWT authentication. + + - **Security Considerations:** + - Keep private key files secure. + - Use strong signing algorithms (e.g., `RS512`, `PS512`, `ES512`). + - Ensure HTTPS is used in production environments. + +.NOTES + Author: Pode Team + License: MIT License +#> +param ( + [string] + $ApiBaseUrl = 'http://localhost:8081/auth/bearer/jwt' +) + +try { + # Determine the script path and Pode module path + $ScriptPath = (Split-Path -Parent -Path (Split-Path -Parent -Path (Split-Path -Parent -Path $MyInvocation.MyCommand.Path))) + $podePath = Split-Path -Parent -Path $ScriptPath + + # Import the Pode module from the source path if it exists, otherwise from installed modules + if (Test-Path -Path "$($podePath)/src/Pode.psm1" -PathType Leaf) { + Import-Module "$($podePath)/src/Pode.psm1" -Force -ErrorAction Stop + } + else { + Import-Module -Name 'Pode' -MaximumVersion 2.99 -ErrorAction Stop + } + + # Define the key storage path + $certsPath = Join-Path -Path $podePath -ChildPath 'examples/Authentication/certs' +} +catch { throw } + +$ApiBaseUrl = 'http://localhost:8081/auth/bearer/jwt' + +Write-Output 'Starting JWT Authentication Tests...' +Write-Output "Checking if certificates directory exists: $certsPath" + +if (-Not (Test-Path $certsPath)) { + Write-Error "Certificate directory does not exist: $certsPath" + Exit +} + + +$algorithms = 'RS256', 'RS384', 'RS512', 'PS256', 'PS384', 'PS512', 'ES256', 'ES384', 'ES512' +# $algorithms = 'PS256', 'PS384', 'PS512', 'ES256', 'ES384', 'ES512' +foreach ($alg in $algorithms) { + Write-Output '-----------------------------------------------' + Write-Output "Testing Algorithm: $alg" + + + #$securePassword = ConvertTo-SecureString 'MySecurePassword' -AsPlainText -Force + $privateKeyPath = "$certsPath/$alg.pfx" + $rsaPaddingScheme = if ($alg.StartsWith('PS')) { + 'Pss' + } + else { + 'Pkcs1V15' + } + + + + if (-Not (Test-Path $privateKeyPath)) { + Write-Warning "Skipping $($alg): Private key file not found ($privateKeyPath)" + Continue + } + + + Write-Output "Generating JWT for $alg..." + + try { + $jwt = ConvertTo-PodeJwt -Certificate $privateKeyPath -RsaPaddingScheme $rsaPaddingScheme -Payload @{ + id = 'id' + name = 'Morty' + Type = 'Human' + username = 'morty' + } + ConvertFrom-PodeJwt -Token $jwt -Certificate $privateKeyPath -RsaPaddingScheme $rsaPaddingScheme + $apiUrl = "$ApiBaseUrl/$alg" + + } + catch { + Write-Error "JWT generation failed for $($alg): $_" + Continue + } + Write-Output "JWT successfully generated for $alg" + + $headers = @{ + 'Authorization' = "Bearer $jwt" + 'Accept' = 'application/json' + } + + Write-Output "Sending request to: $apiUrl" + + try { + $response = Invoke-RestMethod -Uri $apiUrl -Method Get -Headers $headers + Write-Output "Response for $($alg): $($response | ConvertTo-Json -Depth 2)" + } + catch { + Write-Error "API request failed for $($alg): $_" + } + + Write-Output 'Waiting 3 seconds before next test...' + Start-Sleep 3 +} + +Write-Output 'All JWT authentication tests completed!' diff --git a/examples/Authentication/outfile.json b/examples/Authentication/outfile.json new file mode 100644 index 000000000..552651dfe --- /dev/null +++ b/examples/Authentication/outfile.json @@ -0,0 +1 @@ +{"Users":[{"Age":42,"Name":"Deep Thought"},{"Age":1337,"Name":"Leeroy Jenkins"}]} diff --git a/examples/OpenApi-TuttiFrutti.ps1 b/examples/OpenApi-TuttiFrutti.ps1 index e80ff8023..30193ef63 100644 --- a/examples/OpenApi-TuttiFrutti.ps1 +++ b/examples/OpenApi-TuttiFrutti.ps1 @@ -20,6 +20,9 @@ Ignores the server.psd1 configuration file when starting the server. This parameter ensures the server does not load or apply any settings defined in the server.psd1 file, allowing for a fully manual configuration at runtime. +.PARAMETER ShowOpenAPI + Show the OpenAPI definition on console + .EXAMPLE To run the sample: ./OpenApi-TuttiFrutti.ps1 @@ -49,7 +52,10 @@ param( $Daemon, [switch] - $IgnoreServerConfig + $IgnoreServerConfig, + + [switch] + $ShowOpenAPI ) try { @@ -371,7 +377,7 @@ Some useful links: return @{ User = $user } } # jwt with no signature: - New-PodeAuthScheme -Bearer -AsJWT | Add-PodeAuth -Name 'Jwt' -Sessionless -ScriptBlock { + New-PodeAuthBearerScheme -AsJWT | Add-PodeAuth -Name 'Jwt' -Sessionless -ScriptBlock { param($payload) return ConvertFrom-PodeJwt -Token $payload @@ -1028,11 +1034,12 @@ Some useful links: } } + if ($ShowOpenAPI) { + $yaml = Get-PodeOADefinition -Format Yaml -DefinitionTag 'v3.1' + $json = Get-PodeOADefinition -Format Json -DefinitionTag 'v3' - $yaml = Get-PodeOADefinition -Format Yaml -DefinitionTag 'v3.1' - $json = Get-PodeOADefinition -Format Json -DefinitionTag 'v3' + Write-PodeHost "`rYAML Tag: v3.1 Output:`r $yaml" - Write-PodeHost "`rYAML Tag: v3.1 Output:`r $yaml" - - Write-PodeHost "`rJSON Tag: v3 Output:`r $json" + Write-PodeHost "`rJSON Tag: v3 Output:`r $json" + } } \ No newline at end of file diff --git a/examples/Web-AuthDigest.ps1 b/examples/Web-AuthDigest.ps1 deleted file mode 100644 index 7c26eef53..000000000 --- a/examples/Web-AuthDigest.ps1 +++ /dev/null @@ -1,80 +0,0 @@ -<# -.SYNOPSIS - PowerShell script to set up a Pode server with Digest authentication. - -.DESCRIPTION - This script sets up a Pode server that listens on a specified port and uses Digest authentication - for securing access to the server. The authentication details are checked against predefined user data. - -.EXAMPLE - To run the sample: ./Web-AuthDigest.ps1 - - Invoke-RestMethod -Uri http://localhost:8081/users -Method Get - -.LINK - https://github.com/Badgerati/Pode/blob/develop/examples/Web-AuthDigest.ps1 - -.NOTES - Author: Pode Team - License: MIT License -#> -try { - # Determine the script path and Pode module path - $ScriptPath = (Split-Path -Parent -Path $MyInvocation.MyCommand.Path) - $podePath = Split-Path -Parent -Path $ScriptPath - - # Import the Pode module from the source path if it exists, otherwise from installed modules - if (Test-Path -Path "$($podePath)/src/Pode.psm1" -PathType Leaf) { - Import-Module "$($podePath)/src/Pode.psm1" -Force -ErrorAction Stop - } - else { - Import-Module -Name 'Pode' -MaximumVersion 2.99 -ErrorAction Stop - } -} -catch { throw } - -# or just: -# Import-Module Pode - -# create a server, and start listening on port 8081 -Start-PodeServer -Threads 2 { - - # listen on localhost:8081 - Add-PodeEndpoint -Address localhost -Port 8081 -Protocol Http - - # setup digest auth - New-PodeAuthScheme -Digest | Add-PodeAuth -Name 'Validate' -Sessionless -ScriptBlock { - param($username, $params) - - # here you'd check a real user storage, this is just for example - if ($username -ieq 'morty') { - return @{ - User = @{ - ID ='M0R7Y302' - Name = 'Morty' - Type = 'Human' - } - Password = 'pickle' - } - } - - 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 { - Write-PodeJsonResponse -Value @{ - Users = @( - @{ - Name = 'Deep Thought' - Age = 42 - }, - @{ - Name = 'Leeroy Jenkins' - Age = 1337 - } - ) - } - } - -} \ No newline at end of file diff --git a/examples/certs/cert.pem b/examples/certs/cert.pem deleted file mode 100644 index fe7f9249a..000000000 --- a/examples/certs/cert.pem +++ /dev/null @@ -1,21 +0,0 @@ ------BEGIN CERTIFICATE----- -MIIDYDCCAkigAwIBAgIJAKe/1qpK2+6+MA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNV -BAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBX -aWRnaXRzIFB0eSBMdGQwHhcNMjEwODA1MTUzODE1WhcNMjIwODA1MTUzODE1WjBF -MQswCQYDVQQGEwJBVTETMBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UECgwYSW50 -ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB -CgKCAQEA4JoZM7i3KJHC8EOzBRO8bmSAVoRIjvLD2O1B0Axv9+2wQlHByZZZP2Pp -bY3wahczUvDqihGV0iQDeKDUvETnh4MrrJaeXqyt/t9/5wVeuH10p5tfaYe4Mqwd -tVxyAsjLB41445fTGkwvmJi0Ka19GFx232DAT28p5kc8VLb/XXAjuEzeccBOvw53 -ZOSbANg3g2G7m/GYeehtK9vh8FQTDtnMXer6WZ0QNBSF9898KpC1WQsF89t24Ox7 -SJs3uWjFqBwKMbEl6fUMg7I26DY7pYIgKu5QAfUgdF8LZcPv5Fu8c0Np/XdcpuVI -1S4jVmo4xf7/Tr3fFPNJ/r5im19UhwIDAQABo1MwUTAdBgNVHQ4EFgQUbHM52jhC -bekyuSuu+R6hwpCrB10wHwYDVR0jBBgwFoAUbHM52jhCbekyuSuu+R6hwpCrB10w -DwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAy9mnBTkRWHVwK/t8 -E2nYkoDqwwa+dO852JGrl39vzJR02iQUMKF7OFGPf33VmsgJFyznNzZWnOnqg7l4 -6RH5vSymdC7MShKp/jKzScdN1iGS0OFn6ZzJLLVilxj3WMfX3ivv0VdOq46OmRhl -3ymVdqWNXN9Mkz8pPeS2IcluMsy4YAjd76vTLeVpcruKjHT/zYv+mvLilzmCUNO7 -ZEgv8Z/rCGahaGstBnXecLv/fYhAtciEsjVZuwQO74YWSckNnFCxltMFEp4isskU -KA452y1/mdHc/GADPry9Fitjus2cjBIKuw0vwOZlx9nzk/oE4K41SNk41rxEVhYt -azEiUw== ------END CERTIFICATE----- diff --git a/examples/certs/cert_nodes.pem b/examples/certs/cert_nodes.pem deleted file mode 100644 index 23d81a518..000000000 --- a/examples/certs/cert_nodes.pem +++ /dev/null @@ -1,21 +0,0 @@ ------BEGIN CERTIFICATE----- -MIIDYDCCAkigAwIBAgIJAJc72KSCiuAkMA0GCSqGSIb3DQEBCwUAMEUxCzAJBgNV -BAYTAkFVMRMwEQYDVQQIDApTb21lLVN0YXRlMSEwHwYDVQQKDBhJbnRlcm5ldCBX -aWRnaXRzIFB0eSBMdGQwHhcNMjEwODA1MTUzODAwWhcNMjIwODA1MTUzODAwWjBF -MQswCQYDVQQGEwJBVTETMBEGA1UECAwKU29tZS1TdGF0ZTEhMB8GA1UECgwYSW50 -ZXJuZXQgV2lkZ2l0cyBQdHkgTHRkMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIB -CgKCAQEArvYj+XMKYRfU3cKXfeDeX0Tv8zPnZ9hc/lTBc3UNifBjzcReLKbTDz76 -IxI6rmNFcRXJbVDOTE03pIBsqs4+haaGYtwd4tyvTex4iUfURd887WaOwjCtsMsM -w3Pgll8SpZaXvuWboZUWNTtgWNWQ5BhYFQhxcI+iuJecy2WqHkbG9UPjsF7slyAT -eGnuB9O2xAV9qapVMWz10CwqW0HWeaHcLqqM1XGyNxTtU+RkUV16+Qb1rW901Ck+ -mDXoUs4O4UW9jVHKw03DKlv/9v1lnOszveKXvtriOBS3nhFeCpej0TYFvExoE2G1 -dOEvDxBvDkRT0TXVH/VuZ5qBp2qp1wIDAQABo1MwUTAdBgNVHQ4EFgQUpUllOx5o -M+22/y06FYvhKf/ZGyAwHwYDVR0jBBgwFoAUpUllOx5oM+22/y06FYvhKf/ZGyAw -DwYDVR0TAQH/BAUwAwEB/zANBgkqhkiG9w0BAQsFAAOCAQEAfi/dniGTslG4MF7d -p2KxF2/VsXYVnjp4EfpIUBfQwF/6yn3jUJSunWc/iisLp0GXvmyAk56eDBQEWdx+ -cogIKbKAhrMk0JbmRNRJN+6DrKlstgqcP3bVMDYKRD82VrzfcdtMeI70PwH55wPU -V3UHmlni4Gs2js48TjRwypCvTTozNrkDiC89FrlQ7+PhQCcG26GZZpSMXP54allO -U2aQrQv0zCYi1gZwyQ0px/2vPkOD4HEmbEk7hGFkaM88+XutyDxfYG21glz4JzsQ -TPicqsfAItoaOPZ3wQ2wEMhjyum6/V5VoXRJ17lFtG1Vy8GfTt8zTkZyGkGx/zTN -xBJkHQ== ------END CERTIFICATE----- diff --git a/examples/certs/key.pem b/examples/certs/key.pem deleted file mode 100644 index 3cf77f1f9..000000000 --- a/examples/certs/key.pem +++ /dev/null @@ -1,30 +0,0 @@ ------BEGIN ENCRYPTED PRIVATE KEY----- -MIIFHDBOBgkqhkiG9w0BBQ0wQTApBgkqhkiG9w0BBQwwHAQIXZcbs4qNi2kCAggA -MAwGCCqGSIb3DQIJBQAwFAYIKoZIhvcNAwcECIpXQr1lKOXJBIIEyBnrPhHod9qi -aVphPgF0IBTKrRJfWlzkGg/FuUvhxRfJd9xeozt6eCYWYV4TWAJj8mGbDr2pORyd -4loI6Bo3HtpW1WHZmYP4X7OLkCCjNYo/LoE7AX325bkmhs6AWOggWkK072oygRds -qnHXdyzQV0u74mYu1u4yBXhTxzllfgwvKZ+ChrloMcXAgz5+7cS06Jzw97WDjoJG -lTAHpBlFptP+SEfiG/PP78NA+zspKs9yijtRD5aD/pOHsN943AfbxGimd1vK+Xma -6HpCqrdljrgg+/On5jKl9ffOdczyhqZFJyaqgEcZgQiVQwUilz8JK5Y1qqv29pm7 -zvxebdt/PkMlzGr5E4X/Nu3iKk2BhnwSS6W9B7u/bGaH97uHE04CTi9U3SV9H+g9 -JUYTxMfkJ5sdnlxfEo1jjS8YKg/+V2P5bXd5bEp7SgtmoUz/IcDfctiLDUV9Xh/E -JXlEcPq2cx+wpc3fN+bC42ZLoEbbxEVoWRktqJo6ZA4VltEujlbl3WA/qphoFixL -uE+O9s5ekTi+Yk9lIvFd3Tv7tMSCzp3Qkwxu1S2AgiOIReWeC1tp9X6qY/srOrtV -S6hAPclKgGMhyreBdcXk+CrL/vQVo0B8J80xRgz9thC1KY9NNWR1MiBbLxTxXdHJ -kAwsHrm31nvBiV3WDu3ShXQjBRDgCq5b1Di1KRN0z2w7Ht4MMB4hVmgeeWjVG3Ia -jAwxvXrjIXs7D7nzYUpMVxbMyeYkERIQ5czxR7R7rNp9jCCGuy2GGoZjOdZ8Kd4N -NQj/ZLYKIBQGIOkvtQ8UL/Kxe91VM8QpwszvwJXIQgmidDsOcZw7NiGqLJX3mRzD -qRKYl1TXAJnYuBYUNaPbzeNbRE/RvTqz55LNZ0juL39WYnE0gwwP5V3G122d7Msm -KRpE8X1Mv7E8EqtBzdoK3O6YgoTm/njsecUCZfU/c7FKg180Q/z18uVMxQ3nSxEv -wR0rpc5CW9c4MlnomVrlimob5uG0MnkANLKJNtm6+KDyt110g5xUdgF1Jqb5AkeU -HzNWXQlmOw9AhYshTKDwtZVlChxvSC9IwbcxDORkjiiC7TWIiXa8lwyUHoCzS4jb -ACnvbWN+J2a08AXExUJSDmsP6+3mplwFAz2rn38xp0HYQOoRm+Rw5AMtSwN6KYn1 -P9CKxDB8lK8/GF2iELzudiraiDc61l8ELkmFACyt2Fo70tfj4sVQIu7KJhp1jBAU -/CXQAKaY7otZ5fcmwby2rp4eMuNv8aInGZfEderZoz6UivErhr2Rydep+VA9Sbgq -316/z/hGrzwWWa2I1qbuX19bN1jILhsa7YTa2CL/6KB1ghh9lqkWfY6Po8rVN/YP -Kw0R+7+MK2KHXES+NDP5dF5d0KiDJDvGkS//8ZUUGwbJNi+ymrUvhKhTmJoPG3xf -sV0ELNQlkFQt28YrfFSHyB+hVCNnTcy1sMWGUt2X1Thsr6GL717Q+YhnOQZEDd2M -Jhs/4LJaCvrdXIJHQXgtsS2UBAqd4+frJh1pp+SWRT+Xn+uAqfxCjhrJ73bLPcx/ -lOfBtkpjZGaqOIr6kFYDsf+eVXimtkF2fYyCeU7WoS+vML52Cz7+hWjouq53kS3m -2XbYMLV6Z1Bcjbvb8i6uEA== ------END ENCRYPTED PRIVATE KEY----- diff --git a/examples/certs/key_nodes.pem b/examples/certs/key_nodes.pem deleted file mode 100644 index c046b9ff4..000000000 --- a/examples/certs/key_nodes.pem +++ /dev/null @@ -1,28 +0,0 @@ ------BEGIN PRIVATE KEY----- -MIIEvgIBADANBgkqhkiG9w0BAQEFAASCBKgwggSkAgEAAoIBAQCu9iP5cwphF9Td -wpd94N5fRO/zM+dn2Fz+VMFzdQ2J8GPNxF4sptMPPvojEjquY0VxFcltUM5MTTek -gGyqzj6FpoZi3B3i3K9N7HiJR9RF3zztZo7CMK2wywzDc+CWXxKllpe+5ZuhlRY1 -O2BY1ZDkGFgVCHFwj6K4l5zLZaoeRsb1Q+OwXuyXIBN4ae4H07bEBX2pqlUxbPXQ -LCpbQdZ5odwuqozVcbI3FO1T5GRRXXr5BvWtb3TUKT6YNehSzg7hRb2NUcrDTcMq -W//2/WWc6zO94pe+2uI4FLeeEV4Kl6PRNgW8TGgTYbV04S8PEG8ORFPRNdUf9W5n -moGnaqnXAgMBAAECggEAFTy1YysOoHh3Ey/ymYn5FBFXGus69ITzzL9W9//GU+8E -/k4OrFbXmasoS6eDzfUo0bA2UfmUAPkCfwpDpnwAZNKwz0Eus4HcGZZRj0BTyONv -DtX7ECE+hA4xj2v6X+ZMaiMcakSOno9tMaryZ/YMb1NxJaRvuJ0GwGdO1fWSL7hu -NXB2jRkrHAEzb4zudsMwJhOVmPGfN6PZ2ktONfnP06Nz8YcyTipF+QHbxrJ91nM7 -+udY534LTBXjIHJ8Fm8yGbFTJRUmAH+OpTzpmB54HI1b25liN8cZyr6NZHjr0+zT -Uk9V4Cnq8rDIyXnDkfVg3nZSDw3wVPQPsPTmv6U1eQKBgQDb7A9Zs7xW/KFldmgO -gS2cfXqfprvTpl8IebsHYGQX6sYarVHE5awhe1wV2V6v6LUWzta3BFpLer/l4ASk -LpNlTa5+QIFbe5zuWu55bgPKZRPRbdWLzhHN12y+3KWAYEvioxHHizXMDIqubZ1v -JaiF9UGmNWdX7NeYEPIr9R0ghQKBgQDLqeVHCELvizsmVzX5R9e6h0ybgisZAsP9 -z0bNZszzxV0zji65zCkxgCkQW1SMDkJMmpYSWjVp5nR5b9RwXGA7jZ+m2HAttNzq -JyKlTJG39IDmg4Ap/hDjqRWj5La9z5rY/WX7l9lVTjGiQuDbHRccC533jGyU/COD -NuA14lF9qwKBgHLTO+iQCaQ5X2OEgSwhklkEwwOcoLEPSss4E8j0MQ6zzB+dovX1 -HPyWViwqRGAAVpzD/iOsqCCExLEXWBUJJHheKN9OervzPKrO23iXUm9YexJ8EGVg -gLdC5Up6FgeDP9vjXKMdMkeJvNb58JtZxDW9KjvH4l9sD90b6/W7kyupAoGBAIwj -SF97IMvBax7zrXDs7VUtGhp7E/quu3uer6JQVUB7kqkR8bbo84NbI2Zc4a1JdndN -e2v/ZHeNGqIgv/Xcql7wEWX10iKxK7121lEVgcMpW7TB0WOTrb1pMDnI+7FZ87vR -iOX405PuLRrwl9ZNiwRCPh0DJAfUAv+bt+V76ATnAoGBAKJ0jKIqf4eVFyqxIZNp -+tdLQ5aQQQuCNGoY0flD5kAq8lCSLxt+76VMlCxG3gqAYIEz697fjDMsGaLDkmt3 -QZql5UcJrYoppSa9otqRNftdRzfyDZ+7aEYQ5qimrPRFGtHPf9CVj81AI7vkbpBe -4QhebC89xSeGKpd9rAdJXDYz ------END PRIVATE KEY----- diff --git a/examples/certs/pode-cert.cer b/examples/certs/pode-cert.cer deleted file mode 100644 index a548811d6..000000000 Binary files a/examples/certs/pode-cert.cer and /dev/null differ diff --git a/examples/certs/pode-cert.pfx b/examples/certs/pode-cert.pfx deleted file mode 100644 index c4b2e0a4f..000000000 Binary files a/examples/certs/pode-cert.pfx and /dev/null differ diff --git a/pode.build.ps1 b/pode.build.ps1 index ae8862739..67877f5cc 100644 --- a/pode.build.ps1 +++ b/pode.build.ps1 @@ -1237,6 +1237,20 @@ Add-BuildTask TestNoBuild TestDeps, { $configuration.TestResult.OutputFormat = 'NUnitXml' $configuration.Output.Verbosity = $PesterVerbosity $configuration.TestResult.OutputPath = $Script:TestResultFile + $excludeTag = @() + if ( $PSEdition -ne 'Core') { + $excludeTag += 'Exclude_DesktopEdition' + } + if ($IsLinux) { + $excludeTag += 'Exclude_Linux' + } + if ($IsMacOS) { + $excludeTag += 'Exclude_MacOs' + } + if ($IsWindows) { + $excludeTag += 'Exclude_Windows' + } + $configuration.Filter.ExcludeTag = $excludeTag # if run code coverage if enabled if (Test-PodeBuildCanCodeCoverage) { @@ -1712,4 +1726,4 @@ task ReleaseNotes { $categories[$category] | Sort-Object | ForEach-Object { Write-Host $_ } Write-Host '' } -} +} \ No newline at end of file diff --git a/src/Locales/ar/Pode.psd1 b/src/Locales/ar/Pode.psd1 index d9617f469..8544dcd2c 100644 --- a/src/Locales/ar/Pode.psd1 +++ b/src/Locales/ar/Pode.psd1 @@ -259,7 +259,6 @@ certificateThumbprintsNameSupportedOnWindowsExceptionMessage = 'بصمات الإبهام/الاسم للشهادة مدعومة فقط على Windows.' sseConnectionNameRequiredExceptionMessage = "مطلوب اسم اتصال SSE، إما من -Name أو `$WebEvent.Sse.Name" invalidMiddlewareTypeExceptionMessage = 'أحد مكونات Middleware المقدمة من نوع غير صالح. كان المتوقع إما ScriptBlock أو Hashtable، ولكن تم الحصول عليه: {0}' - noSecretForJwtSignatureExceptionMessage = 'لم يتم تقديم أي سر لتوقيع JWT.' modulePathDoesNotExistExceptionMessage = 'مسار الوحدة غير موجود: {0}' taskAlreadyDefinedExceptionMessage = '[المهمة] {0}: المهمة معرفة بالفعل.' verbAlreadyDefinedExceptionMessage = '[الفعل] {0}: تم التعريف بالفعل' @@ -326,4 +325,36 @@ rateLimitRuleDoesNotExistExceptionMessage = 'قاعدة الحد الأقصى للمعدل غير موجودة: {0}' accessLimitRuleAlreadyExistsExceptionMessage = 'تم تعريف قاعدة الحد الأقصى للوصول بالفعل: {0}' accessLimitRuleDoesNotExistExceptionMessage = 'قاعدة الحد الأقصى للوصول غير موجودة: {0}' + missingKeyForAlgorithmExceptionMessage = 'مفتاح {0} مطلوب لخوارزميات {1} ({2}).' + jwtIssuedInFutureExceptionMessage = "الطابع الزمني 'iat' (وقت الإصدار) لرمز JWT مضبوط في المستقبل. الرمز غير صالح بعد." + jwtInvalidIssuerExceptionMessage = "الادعاء 'iss' (المصدر) في JWT غير صالح أو مفقود. المصدر المتوقع: '{0}'." + jwtMissingIssuerExceptionMessage = "JWT يفتقد إلى الادعاء المطلوب 'iss' (المصدر). مطلوب مصدر صالح." + jwtInvalidAudienceExceptionMessage = "الادعاء 'aud' (الجمهور) في JWT غير صالح أو مفقود. الجمهور المتوقع: '{0}'." + jwtMissingAudienceExceptionMessage = "JWT يفتقد إلى الادعاء المطلوب 'aud' (الجمهور). مطلوب جمهور صالح." + jwtInvalidSubjectExceptionMessage = "الادعاء 'sub' (الموضوع) في JWT غير صالح أو مفقود. مطلوب موضوع صالح." + jwtInvalidJtiExceptionMessage = "الادعاء 'jti' (معرّف JWT) في JWT غير صالح أو مفقود. مطلوب معرّف فريد صالح." + jwtAlgorithmMismatchExceptionMessage = 'عدم تطابق خوارزمية JWT: المتوقع {0}، ولكن تم العثور على {1}.' + jwtMissingJtiExceptionMessage = "JWT يفتقد إلى الادعاء المطلوب 'jti' (معرّف JWT)." + deprecatedFunctionWarningMessage = "تحذير: الدالة '{0}' قديمة وسيتم إزالتها في الإصدارات المستقبلية. يرجى استخدام الدالة '{1}' بدلاً منها." + unknownAlgorithmOrInvalidPfxExceptionMessage = 'خوارزمية غير معروفة أو تنسيق PEM غير صالح.' + unknownAlgorithmWithKeySizeExceptionMessage = 'خوارزمية {0} غير معروفة (حجم المفتاح: {1} بت).' + jwtCertificateAuthNotSupportedExceptionMessage = 'مصادقة شهادة JWT مدعومة فقط في PowerShell 7.0 أو أحدث.' + jwtNoExpirationExceptionMessage = "JWT يفتقد إلى الادعاء المطلوب 'exp' (انتهاء الصلاحية)." + bearerTokenAuthMethodNotSupportedExceptionMessage = 'مصادقة رمز Bearer باستخدام نص الطلب مدعومة فقط مع طرق HTTP PUT أو POST أو PATCH.' + certificateNotValidYetExceptionMessage = 'الشهادة {0} غير صالحة بعد. صالحة من: {1} (UTC)' + certificateNotValidForPurposeExceptionMessage = "الشهادة غير صالحة لغرض '{0}'. الأغراض المكتشفة: {1}" + certificateUnknownEkusStrictModeExceptionMessage = 'تحتوي الشهادة على EKUs غير معروفة. وضع التحقق الصارم يرفضها. المكتشف: {0}' + failedToCreateCertificateRequestExceptionMessage = 'فشل في إنشاء طلب الشهادة.' + unsupportedCertificateKeyLengthExceptionMessage = 'طول مفتاح الشهادة غير مدعوم: {0} بت. يُرجى استخدام طول مفتاح مدعوم.' + invalidTypeExceptionMessage = 'خطأ: نوع غير صالح لـ {0}. المتوقع {1}، لكن تم استلام [{2}].' + certificateSignatureInvalidExceptionMessage = 'الشهادة {0} تحتوي على توقيع غير صالح. قد تم العبث بالشهادة أو لم يتم توقيعها من قبل جهة موثوقة.' + certificateUntrustedRootExceptionMessage = 'الشهادة {0} صادرة عن جهة جذر غير موثوقة. يرجى تثبيت شهادة الجذر CA أو استخدام شهادة من جهة موثوقة.' + certificateRevokedExceptionMessage = 'تم إبطال الشهادة {0}. السبب: {1}. يرجى الحصول على شهادة صالحة جديدة.' + certificateExpiredIntermediateExceptionMessage = 'الشهادة {0} موقعة من قبل شهادة وسيطة انتهت صلاحيتها في {1}. سلسلة الشهادات لم تعد صالحة.' + certificateValidationFailedExceptionMessage = 'فشلت عملية التحقق من الشهادة {0}. يرجى التحقق من سلسلة الشهادات وفترة الصلاحية.' + certificateWeakAlgorithmExceptionMessage = 'الشهادة {0} تستخدم خوارزمية تشفير ضعيفة: {1}. يُوصى باستخدام SHA-256 أو أقوى.' + selfSignedCertificatesNotAllowedExceptionMessage = 'لا يُسمح باستخدام الشهادات الموقعة ذاتيًا بسبب قيود الأمان.' + digestTokenAuthMethodNotSupportedExceptionMessage = "ميزة Digest Quality of Protection 'auth-int' مدعومة فقط لطرق HTTP PUT أو POST أو PATCH." + pemCertificateNotSupportedByPwshVersionExceptionMessage = 'تنسيق شهادة PEM غير مدعوم في PowerShell {0}. يرجى استخدام تنسيق شهادة آخر أو الترقية إلى PowerShell 7.0 أو أحدث.' } + diff --git a/src/Locales/de/Pode.psd1 b/src/Locales/de/Pode.psd1 index d85929f70..f7907f482 100644 --- a/src/Locales/de/Pode.psd1 +++ b/src/Locales/de/Pode.psd1 @@ -259,7 +259,6 @@ certificateThumbprintsNameSupportedOnWindowsExceptionMessage = 'Zertifikat-Thumbprints/Name werden nur unter Windows unterstützt.' sseConnectionNameRequiredExceptionMessage = "Ein SSE-Verbindungsname ist erforderlich, entweder von -Name oder `$WebEvent.Sse.Namee" invalidMiddlewareTypeExceptionMessage = 'Eines der angegebenen Middleware-Objekte ist ein ungültiger Typ. Erwartet wurde entweder ein ScriptBlock oder ein Hashtable, aber erhalten wurde: {0}.' - noSecretForJwtSignatureExceptionMessage = 'Es wurde kein Geheimnis für die JWT-Signatur angegeben.' modulePathDoesNotExistExceptionMessage = 'Der Modulpfad existiert nicht: {0}' taskAlreadyDefinedExceptionMessage = '[Aufgabe] {0}: Aufgabe bereits definiert.' verbAlreadyDefinedExceptionMessage = '[Verb] {0}: Bereits definiert.' @@ -326,4 +325,35 @@ rateLimitRuleDoesNotExistExceptionMessage = "Die Rate-Limit-Regel mit dem Namen '{0}' existiert nicht." accessLimitRuleAlreadyExistsExceptionMessage = "Die Zugriffsbeschränkungsregel mit dem Namen '{0}' existiert bereits." accessLimitRuleDoesNotExistExceptionMessage = "Die Zugriffsbeschränkungsregel mit dem Namen '{0}' existiert nicht." + missingKeyForAlgorithmExceptionMessage = 'Ein {0}-Schlüssel ist für {1}-Algorithmen ({2}) erforderlich.' + jwtIssuedInFutureExceptionMessage = "Der 'iat' (Issued At)-Zeitstempel des JWT ist in der Zukunft gesetzt. Das Token ist noch nicht gültig." + jwtInvalidIssuerExceptionMessage = "Der JWT-Anspruch 'iss' (Issuer) ist ungültig oder fehlt. Erwarteter Herausgeber: '{0}'." + jwtMissingIssuerExceptionMessage = "Dem JWT fehlt der erforderliche 'iss' (Issuer)-Anspruch. Ein gültiger Herausgeber ist erforderlich." + jwtInvalidAudienceExceptionMessage = "Der JWT-Anspruch 'aud' (Audience) ist ungültig oder fehlt. Erwartete Zielgruppe: '{0}'." + jwtMissingAudienceExceptionMessage = "Dem JWT fehlt der erforderliche 'aud' (Audience)-Anspruch. Eine gültige Zielgruppe ist erforderlich." + jwtInvalidSubjectExceptionMessage = "Der JWT-Anspruch 'sub' (Subject) ist ungültig oder fehlt. Ein gültiges Subjekt ist erforderlich." + jwtInvalidJtiExceptionMessage = "Der JWT-Anspruch 'jti' (JWT ID) ist ungültig oder fehlt. Eine gültige eindeutige Kennung ist erforderlich." + jwtAlgorithmMismatchExceptionMessage = 'JWT-Algorithmus stimmt nicht überein: Erwartet {0}, gefunden {1}.' + jwtMissingJtiExceptionMessage = "Dem JWT fehlt die erforderliche 'jti' (JWT-ID)-Anspruch." + deprecatedFunctionWarningMessage = "WARNUNG: Die Funktion '{0}' ist veraltet und wird in zukünftigen Versionen entfernt. Bitte verwenden Sie stattdessen die Funktion '{1}'." + unknownAlgorithmOrInvalidPfxExceptionMessage = 'Unbekannter Algorithmus oder ungültiges PEM-Format.' + unknownAlgorithmWithKeySizeExceptionMessage = 'Unbekannter {0}-Algorithmus (Schlüsselgröße: {1} Bit).' + jwtCertificateAuthNotSupportedExceptionMessage = 'JWT-Zertifikatauthentifizierung wird nur in PowerShell 7.0 oder höher unterstützt.' + jwtNoExpirationExceptionMessage = "Dem JWT fehlt der erforderliche 'exp' (Expiration)-Anspruch. Ein Ablaufdatum ist erforderlich." + bearerTokenAuthMethodNotSupportedExceptionMessage = 'Die Bearer-Token-Authentifizierung über den Anfragetext wird nur mit den HTTP-Methoden PUT, POST oder PATCH unterstützt.' + certificateNotValidYetExceptionMessage = 'Zertifikat {0} ist noch NICHT gültig. Gültig ab: {1} (UTC)' + certificateNotValidForPurposeExceptionMessage = "Zertifikat ist NICHT gültig für '{0}'. Gefundene Zwecke: {1}" + certificateUnknownEkusStrictModeExceptionMessage = 'Zertifikat enthält unbekannte EKUs. Der strikte Modus lehnt es ab. Gefunden: {0}' + failedToCreateCertificateRequestExceptionMessage = 'Fehler beim Erstellen einer Zertifikatsanforderung.' + unsupportedCertificateKeyLengthExceptionMessage = 'Nicht unterstützte Zertifikatschlüssellänge: {0} Bit. Bitte verwenden Sie eine unterstützte Schlüssellänge.' + invalidTypeExceptionMessage = 'Fehler: Ungültiger Typ für {0}. Erwartet {1}, aber erhalten [{2}].' + certificateSignatureInvalidExceptionMessage = 'Das Zertifikat {0} hat eine ungültige Signatur. Das Zertifikat wurde möglicherweise manipuliert oder nicht von einer vertrauenswürdigen Stelle signiert.' + certificateUntrustedRootExceptionMessage = 'Das Zertifikat {0} wurde von einer nicht vertrauenswürdigen Stammzertifizierungsstelle ausgestellt. Bitte installieren Sie das Stamm-CA-Zertifikat oder verwenden Sie ein Zertifikat einer vertrauenswürdigen Behörde.' + certificateRevokedExceptionMessage = 'Das Zertifikat {0} wurde widerrufen. Grund: {1}. Bitte fordern Sie ein neues gültiges Zertifikat an.' + certificateExpiredIntermediateExceptionMessage = 'Das Zertifikat {0} wurde von einem Zwischenzertifikat signiert, das am {1} abgelaufen ist. Die Zertifikatskette ist nicht mehr gültig.' + certificateValidationFailedExceptionMessage = 'Die Zertifikatsvalidierung für {0} ist fehlgeschlagen. Bitte überprüfen Sie die Zertifikatskette und die Gültigkeitsdauer.' + certificateWeakAlgorithmExceptionMessage = 'Das Zertifikat {0} verwendet eine schwache kryptografische Algorithmus: {1}. Es wird empfohlen, SHA-256 oder stärker zu verwenden.' + selfSignedCertificatesNotAllowedExceptionMessage = 'Selbstsignierte Zertifikate sind aufgrund von Sicherheitsbeschränkungen nicht erlaubt.' + digestTokenAuthMethodNotSupportedExceptionMessage = "Digest Quality of Protection 'auth-int' wird nur für die HTTP-Methoden PUT, POST oder PATCH unterstützt." + pemCertificateNotSupportedByPwshVersionExceptionMessage = 'Das PEM-Zertifikatsformat wird in PowerShell {0} nicht unterstützt. Bitte verwenden Sie ein anderes Zertifikatsformat oder aktualisieren Sie auf PowerShell 7.0 oder höher.' } \ No newline at end of file diff --git a/src/Locales/en-us/Pode.psd1 b/src/Locales/en-us/Pode.psd1 index c2ac6d2b0..4ea65fe1a 100644 --- a/src/Locales/en-us/Pode.psd1 +++ b/src/Locales/en-us/Pode.psd1 @@ -259,7 +259,6 @@ certificateThumbprintsNameSupportedOnWindowsExceptionMessage = 'Certificate Thumbprints/Name are only supported on Windows OS.' sseConnectionNameRequiredExceptionMessage = "An SSE connection Name is required, either from -Name or `$WebEvent.Sse.Name" invalidMiddlewareTypeExceptionMessage = 'One of the Middlewares supplied is an invalid type. Expected either a ScriptBlock or Hashtable, but got: {0}' - noSecretForJwtSignatureExceptionMessage = 'No secret supplied for JWT signature.' modulePathDoesNotExistExceptionMessage = 'The module path does not exist: {0}' taskAlreadyDefinedExceptionMessage = '[Task] {0}: Task already defined.' verbAlreadyDefinedExceptionMessage = '[Verb] {0}: Already defined' @@ -326,4 +325,35 @@ rateLimitRuleDoesNotExistExceptionMessage = "A rate limit rule with the name '{0}' does not exist." accessLimitRuleAlreadyExistsExceptionMessage = "An access limit rule with the name '{0}' already exists." accessLimitRuleDoesNotExistExceptionMessage = "An access limit rule with the name '{0}' does not exist." + missingKeyForAlgorithmExceptionMessage = 'A {0} key is required for {1} algorithms ({2}).' + jwtIssuedInFutureExceptionMessage = "The JWT's 'iat' (Issued At) timestamp is set in the future. The token is not valid yet." + jwtInvalidIssuerExceptionMessage = "The JWT 'iss' (Issuer) claim is invalid or missing. Expected issuer: '{0}'." + jwtMissingIssuerExceptionMessage = "The JWT is missing the required 'iss' (Issuer) claim. A valid issuer is required." + jwtInvalidAudienceExceptionMessage = "The JWT 'aud' (Audience) claim is invalid or missing. Expected audience: '{0}'." + jwtMissingAudienceExceptionMessage = "The JWT is missing the required 'aud' (Audience) claim. A valid audience is required." + jwtInvalidSubjectExceptionMessage = "The JWT 'sub' (Subject) claim is invalid or missing. A valid subject is required." + jwtInvalidJtiExceptionMessage = "The JWT 'jti' (JWT ID) claim is invalid or missing. A valid unique identifier is required." + jwtAlgorithmMismatchExceptionMessage = 'JWT algorithm mismatch: Expected {0}, found {1}.' + jwtMissingJtiExceptionMessage = "The JWT is missing the required 'jti' (JWT ID) claim." + deprecatedFunctionWarningMessage = "WARNING: The function '{0}' is deprecated and will be removed in future releases. Please use the '{1}' function instead." + unknownAlgorithmOrInvalidPfxExceptionMessage = 'Unknown algorithm or invalid PEM format.' + unknownAlgorithmWithKeySizeExceptionMessage = 'Unknown {0} algorithm (Key Size: {1} bits).' + jwtCertificateAuthNotSupportedExceptionMessage = 'JWT certificate authentication is supported only in PowerShell 7.0 or greater.' + jwtNoExpirationExceptionMessage = 'The JWT is missing the required expiration time (exp) claim. A valid expiration time is required.' + bearerTokenAuthMethodNotSupportedExceptionMessage = 'Bearer token authentication using the request body is only supported with HTTP PUT, POST, or PATCH methods.' + certificateNotValidYetExceptionMessage = 'Certificate {0} is NOT valid yet. Valid from: {1} (UTC)' + certificateNotValidForPurposeExceptionMessage = "Certificate is NOT valid for '{0}'. Found purposes: {1}" + certificateUnknownEkusStrictModeExceptionMessage = 'Certificate contains unknown EKUs. Strict mode rejects it. Found: {0}' + failedToCreateCertificateRequestExceptionMessage = 'Failed to generate a certificate request.' + unsupportedCertificateKeyLengthExceptionMessage = 'Unsupported certificate key length: {0} bits. Please use a supported key length.' + invalidTypeExceptionMessage = 'Error: Invalid type for {0}. Expected {1}, but received [{2}].' + certificateSignatureInvalidExceptionMessage = 'The certificate {0} has an invalid signature. The certificate may have been tampered with or was not signed by a trusted authority.' + certificateUntrustedRootExceptionMessage = 'The certificate {0} is issued by an untrusted root. Please install the root CA certificate or use a certificate from a trusted authority.' + certificateRevokedExceptionMessage = 'The certificate {0} has been revoked. Reason: {1}. Please obtain a new valid certificate.' + certificateExpiredIntermediateExceptionMessage = 'The certificate {0} is signed by an intermediate certificate that has expired on {1}. The certificate chain is no longer valid.' + certificateValidationFailedExceptionMessage = 'Certificate validation failed for {0}. Please check the certificate chain and validity period.' + certificateWeakAlgorithmExceptionMessage = 'The certificate {0} uses a weak cryptographic algorithm: {1}. It is recommended to use SHA-256 or stronger.' + selfSignedCertificatesNotAllowedExceptionMessage = 'Self-signed certificates are not permitted due to security restrictions.' + digestTokenAuthMethodNotSupportedExceptionMessage = "Digest Quality of Protection 'auth-int' is only supported for HTTP PUT, POST, or PATCH methods." + pemCertificateNotSupportedByPwshVersionExceptionMessage = 'PEM certificate format is not supported in PowerShell {0}. Please use a different certificate format or upgrade to PowerShell 7.0 or later.' } \ No newline at end of file diff --git a/src/Locales/en/Pode.psd1 b/src/Locales/en/Pode.psd1 index fc04db0d2..2c4789cfd 100644 --- a/src/Locales/en/Pode.psd1 +++ b/src/Locales/en/Pode.psd1 @@ -259,7 +259,6 @@ certificateThumbprintsNameSupportedOnWindowsExceptionMessage = 'Certificate Thumbprints/Name are only supported on Windows OS.' sseConnectionNameRequiredExceptionMessage = "An SSE connection Name is required, either from -Name or `$WebEvent.Sse.Name" invalidMiddlewareTypeExceptionMessage = 'One of the Middlewares supplied is an invalid type. Expected either a ScriptBlock or Hashtable, but got: {0}' - noSecretForJwtSignatureExceptionMessage = 'No secret supplied for JWT signature.' modulePathDoesNotExistExceptionMessage = 'The module path does not exist: {0}' taskAlreadyDefinedExceptionMessage = '[Task] {0}: Task already defined.' verbAlreadyDefinedExceptionMessage = '[Verb] {0}: Already defined' @@ -326,4 +325,36 @@ rateLimitRuleDoesNotExistExceptionMessage = "A Rate Limit Rule with the name '{0}' does not exist." accessLimitRuleAlreadyExistsExceptionMessage = "An Access Limit Rule with the name '{0}' already exists." accessLimitRuleDoesNotExistExceptionMessage = "An Access Limit Rule with the name '{0}' does not exist." + missingKeyForAlgorithmExceptionMessage = 'A {0} key is required for {1} algorithms ({2}).' + jwtIssuedInFutureExceptionMessage = "The JWT's 'iat' (Issued At) timestamp is set in the future. The token is not valid yet." + jwtInvalidIssuerExceptionMessage = "The JWT 'iss' (Issuer) claim is invalid or missing. Expected issuer: '{0}'." + jwtMissingIssuerExceptionMessage = "The JWT is missing the required 'iss' (Issuer) claim. A valid issuer is required." + jwtInvalidAudienceExceptionMessage = "The JWT 'aud' (Audience) claim is invalid or missing. Expected audience: '{0}'." + jwtMissingAudienceExceptionMessage = "The JWT is missing the required 'aud' (Audience) claim. A valid audience is required." + jwtInvalidSubjectExceptionMessage = "The JWT 'sub' (Subject) claim is invalid or missing. A valid subject is required." + jwtInvalidJtiExceptionMessage = "The JWT 'jti' (JWT ID) claim is invalid or missing. A valid unique identifier is required." + jwtAlgorithmMismatchExceptionMessage = 'JWT algorithm mismatch: Expected {0}, found {1}.' + jwtMissingJtiExceptionMessage = "The JWT is missing the required 'jti' (JWT ID) claim." + deprecatedFunctionWarningMessage = "WARNING: The function '{0}' is deprecated and will be removed in future releases. Please use the '{1}' function instead." + unknownAlgorithmOrInvalidPfxExceptionMessage = 'Unknown algorithm or invalid PFX format.' + unknownAlgorithmWithKeySizeExceptionMessage = 'Unknown {0} algorithm (Key Size: {1} bits).' + jwtCertificateAuthNotSupportedExceptionMessage = 'JWT certificate authentication is supported only in PowerShell 7.0 or greater.' + jwtNoExpirationExceptionMessage = 'The JWT is missing the required expiration time (exp) claim. A valid expiration time is required.' + bearerTokenAuthMethodNotSupportedExceptionMessage = 'Bearer token authentication using the request body is only supported with HTTP PUT, POST, or PATCH methods.' + certificateNotValidYetExceptionMessage = 'Certificate {0} is NOT valid yet. Valid from: {1} (UTC)' + certificateNotValidForPurposeExceptionMessage = "Certificate is NOT valid for '{0}'. Found purposes: {1}" + certificateUnknownEkusStrictModeExceptionMessage = 'Certificate contains unknown EKUs. Strict mode rejects it. Found: {0}' + failedToCreateCertificateRequestExceptionMessage = 'Failed to generate a certificate request.' + unsupportedCertificateKeyLengthExceptionMessage = 'Unsupported certificate key length: {0} bits. Please use a supported key length.' + invalidTypeExceptionMessage = 'Error: Invalid type for {0}. Expected {1}, but received [{2}].' + certificateSignatureInvalidExceptionMessage = 'The certificate {0} has an invalid signature. The certificate may have been tampered with or was not signed by a trusted authority.' + certificateUntrustedRootExceptionMessage = 'The certificate {0} is issued by an untrusted root. Please install the root CA certificate or use a certificate from a trusted authority.' + certificateRevokedExceptionMessage = 'The certificate {0} has been revoked. Reason: {1}. Please obtain a new valid certificate.' + certificateExpiredIntermediateExceptionMessage = 'The certificate {0} is signed by an intermediate certificate that has expired on {1}. The certificate chain is no longer valid.' + certificateValidationFailedExceptionMessage = 'Certificate validation failed for {0}. Please check the certificate chain and validity period.' + certificateWeakAlgorithmExceptionMessage = 'The certificate {0} uses a weak cryptographic algorithm: {1}. It is recommended to use SHA-256 or stronger.' + selfSignedCertificatesNotAllowedExceptionMessage = 'Self-signed certificates are not permitted due to security restrictions.' + digestTokenAuthMethodNotSupportedExceptionMessage = "Digest Quality of Protection 'auth-int' is only supported for HTTP PUT, POST, or PATCH methods." + pemCertificateNotSupportedByPwshVersionExceptionMessage = 'PEM certificate format is not supported in PowerShell {0}. Please use a different certificate format or upgrade to PowerShell 7.0 or later.' + } \ No newline at end of file diff --git a/src/Locales/es/Pode.psd1 b/src/Locales/es/Pode.psd1 index d7db64e6c..307b17c42 100644 --- a/src/Locales/es/Pode.psd1 +++ b/src/Locales/es/Pode.psd1 @@ -259,7 +259,6 @@ certificateThumbprintsNameSupportedOnWindowsExceptionMessage = 'Las huellas digitales/nombres de certificados solo son compatibles con Windows.' sseConnectionNameRequiredExceptionMessage = "Se requiere un nombre de conexión SSE, ya sea de -Name o `$WebEvent.Sse.Name" invalidMiddlewareTypeExceptionMessage = 'Uno de los Middlewares suministrados es de un tipo no válido. Se esperaba ScriptBlock o Hashtable, pero se obtuvo: {0}' - noSecretForJwtSignatureExceptionMessage = 'No se suministró ningún secreto para la firma JWT.' modulePathDoesNotExistExceptionMessage = 'La ruta del módulo no existe: {0}' taskAlreadyDefinedExceptionMessage = '[Tarea] {0}: Tarea ya definida.' verbAlreadyDefinedExceptionMessage = '[Verbo] {0}: Ya está definido' @@ -326,4 +325,35 @@ rateLimitRuleDoesNotExistExceptionMessage = "La regla de límite de velocidad con el nombre '{0}' no existe." accessLimitRuleAlreadyExistsExceptionMessage = "La regla de límite de acceso con el nombre '{0}' ya existe." accessLimitRuleDoesNotExistExceptionMessage = "La regla de límite de acceso con el nombre '{0}' no existe." + missingKeyForAlgorithmExceptionMessage = 'Se requiere una clave {0} para los algoritmos {1} ({2}).' + jwtIssuedInFutureExceptionMessage = "La marca de tiempo 'iat' (Issued At) del JWT está configurada en el futuro. El token aún no es válido." + jwtInvalidIssuerExceptionMessage = "La reclamación 'iss' (Issuer) del JWT es inválida o falta. Emisor esperado: '{0}'." + jwtMissingIssuerExceptionMessage = "El JWT no tiene la reclamación obligatoria 'iss' (Issuer). Se requiere un emisor válido." + jwtInvalidAudienceExceptionMessage = "La reclamación 'aud' (Audience) del JWT es inválida o falta. Audiencia esperada: '{0}'." + jwtMissingAudienceExceptionMessage = "El JWT no tiene la reclamación obligatoria 'aud' (Audience). Se requiere una audiencia válida." + jwtInvalidSubjectExceptionMessage = "La reclamación 'sub' (Subject) del JWT es inválida o falta. Se requiere un sujeto válido." + jwtInvalidJtiExceptionMessage = "La reclamación 'jti' (JWT ID) del JWT es inválida o falta. Se requiere un identificador único válido." + jwtAlgorithmMismatchExceptionMessage = 'Incompatibilidad de algoritmo JWT: Se esperaba {0}, pero se encontró {1}.' + jwtMissingJtiExceptionMessage = "El JWT no tiene la reclamación obligatoria 'jti' (JWT ID)." + deprecatedFunctionWarningMessage = "ADVERTENCIA: La función '{0}' está obsoleta y se eliminará en futuras versiones. Por favor, use la función '{1}' en su lugar." + unknownAlgorithmOrInvalidPfxExceptionMessage = 'Algoritmo desconocido o formato PEM no válido.' + unknownAlgorithmWithKeySizeExceptionMessage = 'Algoritmo {0} desconocido (Tamaño de clave: {1} bits).' + jwtCertificateAuthNotSupportedExceptionMessage = 'La autenticación de certificados JWT solo es compatible con PowerShell 7.0 o superior.' + jwtNoExpirationExceptionMessage = "El JWT no tiene la reclamación 'exp' (Expiration). Se requiere una fecha de expiración válida." + bearerTokenAuthMethodNotSupportedExceptionMessage = 'La autenticación con token Bearer usando el cuerpo de la solicitud solo es compatible con los métodos HTTP PUT, POST o PATCH.' + certificateNotValidYetExceptionMessage = 'El certificado {0} aún NO es válido. Válido desde: {1} (UTC)' + certificateNotValidForPurposeExceptionMessage = "El certificado NO es válido para '{0}'. Propósitos encontrados: {1}" + certificateUnknownEkusStrictModeExceptionMessage = 'El certificado contiene EKUs desconocidos. El modo estricto lo rechaza. Encontrado: {0}' + failedToCreateCertificateRequestExceptionMessage = 'Error al generar una solicitud de certificado.' + unsupportedCertificateKeyLengthExceptionMessage = 'Longitud de clave de certificado no compatible: {0} bits. Utilice una longitud de clave compatible.' + invalidTypeExceptionMessage = 'Error: Tipo no válido para {0}. Se esperaba {1}, pero se recibió [{2}].' + certificateSignatureInvalidExceptionMessage = 'El certificado {0} tiene una firma no válida. Puede haber sido alterado o no haber sido firmado por una autoridad de confianza.' + certificateUntrustedRootExceptionMessage = 'El certificado {0} ha sido emitido por una autoridad raíz no confiable. Por favor, instale el certificado de la CA raíz o utilice un certificado de una autoridad confiable.' + certificateRevokedExceptionMessage = 'El certificado {0} ha sido revocado. Razón: {1}. Por favor, obtenga un nuevo certificado válido.' + certificateExpiredIntermediateExceptionMessage = 'El certificado {0} fue firmado por un certificado intermedio que expiró el {1}. La cadena de certificados ya no es válida.' + certificateValidationFailedExceptionMessage = 'La validación del certificado ha fallado para {0}. Por favor, verifique la cadena de certificados y el período de validez.' + certificateWeakAlgorithmExceptionMessage = 'El certificado {0} usa un algoritmo criptográfico débil: {1}. Se recomienda usar SHA-256 o superior.' + selfSignedCertificatesNotAllowedExceptionMessage = 'Los certificados autofirmados no están permitidos debido a restricciones de seguridad.' + digestTokenAuthMethodNotSupportedExceptionMessage = "Digest Quality of Protection 'auth-int' solo es compatible con los métodos HTTP PUT, POST o PATCH." + pemCertificateNotSupportedByPwshVersionExceptionMessage = 'El formato de certificado PEM no es compatible con PowerShell {0}. Utilice un formato de certificado diferente o actualice a PowerShell 7.0 o posterior.' } \ No newline at end of file diff --git a/src/Locales/fr/Pode.psd1 b/src/Locales/fr/Pode.psd1 index a0d769eab..4c719224e 100644 --- a/src/Locales/fr/Pode.psd1 +++ b/src/Locales/fr/Pode.psd1 @@ -259,7 +259,6 @@ certificateThumbprintsNameSupportedOnWindowsExceptionMessage = 'Les empreintes digitales/Noms de certificat ne sont pris en charge que sous Windows.' sseConnectionNameRequiredExceptionMessage = "Un nom de connexion SSE est requis, soit de -Name soit de `$WebEvent.Sse.Name" invalidMiddlewareTypeExceptionMessage = "Un des Middlewares fournis est d'un type non valide. Attendu ScriptBlock ou Hashtable, mais a obtenu : {0}" - noSecretForJwtSignatureExceptionMessage = 'Aucun secret fourni pour la signature JWT.' modulePathDoesNotExistExceptionMessage = "Le chemin du module n'existe pas : {0}" taskAlreadyDefinedExceptionMessage = '[Tâche] {0} : Tâche déjà définie.' verbAlreadyDefinedExceptionMessage = '[Verbe] {0} : Déjà défini' @@ -326,4 +325,35 @@ rateLimitRuleDoesNotExistExceptionMessage = "La règle de limite de taux '{0}' n'existe pas." accessLimitRuleAlreadyExistsExceptionMessage = "Une règle de limite d'accès nommée '{0}' existe déjà." accessLimitRuleDoesNotExistExceptionMessage = "La règle de limite d'accès '{0}' n'existe pas." -} \ No newline at end of file + missingKeyForAlgorithmExceptionMessage = 'Une clé {0} est requise pour les algorithmes {1} ({2}).' + jwtIssuedInFutureExceptionMessage = "L'horodatage 'iat' (Issued At) du JWT est défini dans le futur. Le jeton n'est pas encore valide." + jwtInvalidIssuerExceptionMessage = "La revendication 'iss' (Issuer) du JWT est invalide ou absente. Émetteur attendu : '{0}'." + jwtMissingIssuerExceptionMessage = "Le JWT ne contient pas la revendication obligatoire 'iss' (Issuer). Un émetteur valide est requis." + jwtInvalidAudienceExceptionMessage = "La revendication 'aud' (Audience) du JWT est invalide ou absente. Audience attendue : '{0}'." + jwtMissingAudienceExceptionMessage = "Le JWT ne contient pas la revendication obligatoire 'aud' (Audience). Une audience valide est requise." + jwtInvalidSubjectExceptionMessage = "La revendication 'sub' (Subject) du JWT est invalide ou absente. Un sujet valide est requis." + jwtInvalidJtiExceptionMessage = "La revendication 'jti' (JWT ID) du JWT est invalide ou absente. Un identifiant unique valide est requis." + jwtAlgorithmMismatchExceptionMessage = "Incohérence d'algorithme JWT : attendu {0}, trouvé {1}." + jwtMissingJtiExceptionMessage = "Le JWT ne contient pas la revendication obligatoire 'jti' (JWT ID)." + deprecatedFunctionWarningMessage = "AVERTISSEMENT : La fonction '{0}' est obsolète et sera supprimée dans les futures versions. Veuillez utiliser la fonction '{1}' à la place." + unknownAlgorithmOrInvalidPfxExceptionMessage = 'Algorithme inconnu ou format PEM invalide.' + unknownAlgorithmWithKeySizeExceptionMessage = 'Algorithme {0} inconnu (Taille de clé : {1} bits).' + jwtCertificateAuthNotSupportedExceptionMessage = "L'authentification par certificat JWT est prise en charge uniquement à partir de PowerShell 7.0." + jwtNoExpirationExceptionMessage = "Le JWT ne contient pas la revendication obligatoire 'exp' (Expiration). Une expiration valide est requise." + bearerTokenAuthMethodNotSupportedExceptionMessage = "L'authentification par jeton Bearer via le corps de la requête est prise en charge uniquement avec les méthodes HTTP PUT, POST ou PATCH." + certificateNotValidYetExceptionMessage = "Le certificat {0} n'est PAS encore valide. Valide à partir du : {1} (UTC)" + certificateNotValidForPurposeExceptionMessage = "Le certificat n'est PAS valide pour '{0}'. Objectifs trouvés : {1}" + certificateUnknownEkusStrictModeExceptionMessage = 'Le certificat contient des EKUs inconnus. Le mode strict le rejette. Trouvé : {0}' + failedToCreateCertificateRequestExceptionMessage = 'Échec de la génération de la demande de certificat.' + unsupportedCertificateKeyLengthExceptionMessage = 'Longueur de clé de certificat non prise en charge : {0} bits. Veuillez utiliser une longueur de clé prise en charge.' + invalidTypeExceptionMessage = 'Erreur : Type invalide pour {0}. Attendu {1}, mais reçu [{2}].' + certificateSignatureInvalidExceptionMessage = "Le certificat {0} a une signature invalide. Il a peut-être été altéré ou n'a pas été signé par une autorité de confiance." + certificateUntrustedRootExceptionMessage = "Le certificat {0} est émis par une autorité racine non fiable. Veuillez installer le certificat CA racine ou utiliser un certificat d'une autorité de confiance." + certificateRevokedExceptionMessage = 'Le certificat {0} a été révoqué. Raison : {1}. Veuillez obtenir un nouveau certificat valide.' + certificateExpiredIntermediateExceptionMessage = "Le certificat {0} est signé par un certificat intermédiaire qui a expiré le {1}. La chaîne de certificats n'est plus valide." + certificateValidationFailedExceptionMessage = 'La validation du certificat a échoué pour {0}. Veuillez vérifier la chaîne de certificats et la période de validité.' + certificateWeakAlgorithmExceptionMessage = "Le certificat {0} utilise un algorithme cryptographique faible : {1}. Il est recommandé d'utiliser SHA-256 ou plus sécurisé." + selfSignedCertificatesNotAllowedExceptionMessage = 'Les certificats auto-signés ne sont pas autorisés en raison de restrictions de sécurité.' + digestTokenAuthMethodNotSupportedExceptionMessage = "Digest Quality of Protection 'auth-int' est uniquement pris en charge pour les méthodes HTTP PUT, POST ou PATCH." + pemCertificateNotSupportedByPwshVersionExceptionMessage = "Le format de certificat PEM n'est pas pris en charge dans PowerShell {0}. Veuillez utiliser un autre format de certificat ou passer à PowerShell 7.0 ou une version ultérieure." +} diff --git a/src/Locales/it/Pode.psd1 b/src/Locales/it/Pode.psd1 index 5dd3c47f4..9c2b5bc08 100644 --- a/src/Locales/it/Pode.psd1 +++ b/src/Locales/it/Pode.psd1 @@ -259,7 +259,6 @@ certificateThumbprintsNameSupportedOnWindowsExceptionMessage = 'Impronte digitali/nome del certificato supportati solo su Windows OS.' sseConnectionNameRequiredExceptionMessage = "È richiesto un nome di connessione SSE, sia da -Name che da `$WebEvent.Sse.Name" invalidMiddlewareTypeExceptionMessage = 'Uno dei Middleware forniti è di un tipo non valido. Previsto ScriptBlock o Hashtable, ma ottenuto: {0}' - noSecretForJwtSignatureExceptionMessage = "Nessun 'secret' fornito per la firma JWT." modulePathDoesNotExistExceptionMessage = 'Il percorso del modulo non esiste: {0}' taskAlreadyDefinedExceptionMessage = '[Attività] {0}: Attività già definita.' verbAlreadyDefinedExceptionMessage = '[Verbo] {0}: Già definito' @@ -326,4 +325,35 @@ rateLimitRuleDoesNotExistExceptionMessage = "La regola di limitazione del tasso con il nome '{0}' non esiste." accessLimitRuleAlreadyExistsExceptionMessage = "Una regola di limitazione dell'accesso con il nome '{0}' esiste già." accessLimitRuleDoesNotExistExceptionMessage = "La regola di limitazione dell'accesso con il nome '{0}' non esiste." + missingKeyForAlgorithmExceptionMessage = 'È necessaria una chiave {0} per gli algoritmi {1} ({2}).' + jwtIssuedInFutureExceptionMessage = "Il timestamp 'iat' (Issued At) del JWT è impostato nel futuro. Il token non è ancora valido." + jwtInvalidIssuerExceptionMessage = "Il claim 'iss' (Issuer) del JWT non è valido o è mancante. Emittente previsto: '{0}'." + jwtMissingIssuerExceptionMessage = "Il JWT non ha il claim obbligatorio 'iss' (Issuer). È richiesto un emittente valido." + jwtInvalidAudienceExceptionMessage = "Il claim 'aud' (Audience) del JWT non è valido o è mancante. Pubblico previsto: '{0}'." + jwtMissingAudienceExceptionMessage = "Il JWT non ha il claim obbligatorio 'aud' (Audience). È richiesto un pubblico valido." + jwtInvalidSubjectExceptionMessage = "Il claim 'sub' (Subject) del JWT non è valido o è mancante. È richiesto un soggetto valido." + jwtInvalidJtiExceptionMessage = "Il claim 'jti' (JWT ID) del JWT non è valido o è mancante. È richiesto un identificatore univoco valido." + jwtAlgorithmMismatchExceptionMessage = "Mancata corrispondenza dell'algoritmo JWT: previsto {0}, trovato {1}." + jwtMissingJtiExceptionMessage = "Il JWT non ha il claim obbligatorio 'jti' (JWT ID)." + deprecatedFunctionWarningMessage = "ATTENZIONE: La funzione '{0}' è obsoleta e verrà rimossa nelle versioni future. Si prega di utilizzare invece la funzione '{1}'." + unknownAlgorithmOrInvalidPfxExceptionMessage = 'Algoritmo sconosciuto o formato PEM non valido.' + unknownAlgorithmWithKeySizeExceptionMessage = 'Algoritmo {0} sconosciuto (Dimensione chiave: {1} bit).' + jwtCertificateAuthNotSupportedExceptionMessage = "L'autenticazione tramite certificato JWT è supportata solo in PowerShell 7.0 o versioni successive." + jwtNoExpirationExceptionMessage = "Il JWT non ha il claim obbligatorio 'exp' (Expiration). È richiesta una data di scadenza valida." + bearerTokenAuthMethodNotSupportedExceptionMessage = "L'autenticazione con token Bearer tramite il corpo della richiesta è supportata solo con i metodi HTTP PUT, POST o PATCH." + certificateNotValidYetExceptionMessage = 'Il certificato {0} NON è ancora valido. Valido dal: {1} (UTC)' + certificateNotValidForPurposeExceptionMessage = "Il certificato NON è valido per '{0}'. Scopi trovati: {1}" + certificateUnknownEkusStrictModeExceptionMessage = 'Il certificato contiene EKU sconosciuti. La modalità rigorosa lo rifiuta. Trovato: {0}' + failedToCreateCertificateRequestExceptionMessage = 'Errore nella generazione della richiesta di certificato.' + unsupportedCertificateKeyLengthExceptionMessage = 'Lunghezza della chiave del certificato non supportata: {0} bit. Utilizzare una lunghezza di chiave supportata.' + invalidTypeExceptionMessage = 'Errore: Tipo non valido per {0}. Atteso {1}, ma ricevuto [{2}].' + certificateSignatureInvalidExceptionMessage = "Il certificato {0} ha una firma non valida. Potrebbe essere stato manomesso o non firmato da un'autorità attendibile." + certificateUntrustedRootExceptionMessage = "Il certificato {0} è stato emesso da un'autorità radice non affidabile. Si prega di installare il certificato CA radice o utilizzare un certificato di un'autorità affidabile." + certificateRevokedExceptionMessage = 'Il certificato {0} è stato revocato. Motivo: {1}. Si prega di ottenere un nuovo certificato valido.' + certificateExpiredIntermediateExceptionMessage = 'Il certificato {0} è firmato da un certificato intermedio che è scaduto il {1}. La catena di certificati non è più valida.' + certificateValidationFailedExceptionMessage = 'La validazione del certificato per {0} è fallita. Si prega di verificare la catena di certificati e il periodo di validità.' + certificateWeakAlgorithmExceptionMessage = 'Il certificato {0} utilizza un algoritmo crittografico debole: {1}. Si consiglia di utilizzare SHA-256 o superiore.' + selfSignedCertificatesNotAllowedExceptionMessage = 'I certificati auto-firmati non sono consentiti a causa di restrizioni di sicurezza.' + digestTokenAuthMethodNotSupportedExceptionMessage = "Digest Quality of Protection 'auth-int' è supportato solo per i metodi HTTP PUT, POST o PATCH." + pemCertificateNotSupportedByPwshVersionExceptionMessage = "Il formato del certificato PEM non è supportato in PowerShell {0}. Utilizzare un formato di certificato diverso o eseguire l'aggiornamento a PowerShell 7.0 o versioni successive." } \ No newline at end of file diff --git a/src/Locales/ja/Pode.psd1 b/src/Locales/ja/Pode.psd1 index 43d8243bc..5fb6e364e 100644 --- a/src/Locales/ja/Pode.psd1 +++ b/src/Locales/ja/Pode.psd1 @@ -259,7 +259,6 @@ certificateThumbprintsNameSupportedOnWindowsExceptionMessage = 'Certificate Thumbprints/NameはWindowsでのみサポートされています。' sseConnectionNameRequiredExceptionMessage = "-Nameまたは`$WebEvent.Sse.NameからSSE接続名が必要です。" invalidMiddlewareTypeExceptionMessage = '提供されたMiddlewaresの1つが無効な型です。ScriptBlockまたはHashtableのいずれかを期待しましたが、次を取得しました: {0}' - noSecretForJwtSignatureExceptionMessage = 'JWT署名に対する秘密が提供されていません。' modulePathDoesNotExistExceptionMessage = 'モジュールパスが存在しません: {0}' taskAlreadyDefinedExceptionMessage = '[タスク] {0}: タスクは既に定義されています。' verbAlreadyDefinedExceptionMessage = '[動詞] {0}: すでに定義されています' @@ -326,4 +325,35 @@ rateLimitRuleDoesNotExistExceptionMessage = "名前が '{0}' のレート制限ルールは存在しません。" accessLimitRuleAlreadyExistsExceptionMessage = "名前が '{0}' のアクセス制限ルールは既に存在します。" accessLimitRuleDoesNotExistExceptionMessage = "名前が '{0}' のアクセス制限ルールは存在しません。" + missingKeyForAlgorithmExceptionMessage = '{1} アルゴリズム ({2}) には {0} キーが必要です。' + jwtIssuedInFutureExceptionMessage = "JWTの'iat'(発行時刻)タイムスタンプが未来の日付になっています。トークンはまだ有効ではありません。" + jwtInvalidIssuerExceptionMessage = "JWTの'iss'(発行者)クレームが無効または欠落しています。期待される発行者: '{0}'。" + jwtMissingIssuerExceptionMessage = "JWTに必要な'iss'(発行者)クレームがありません。有効な発行者が必要です。" + jwtInvalidAudienceExceptionMessage = "JWTの'aud'(受信者)クレームが無効または欠落しています。期待される受信者: '{0}'。" + jwtMissingAudienceExceptionMessage = "JWTに必要な'aud'(受信者)クレームがありません。有効な受信者が必要です。" + jwtInvalidSubjectExceptionMessage = "JWTの'sub'(対象)クレームが無効または欠落しています。有効な対象が必要です。" + jwtInvalidJtiExceptionMessage = "JWTの'jti'(JWT ID)クレームが無効または欠落しています。有効な一意識別子が必要です。" + jwtAlgorithmMismatchExceptionMessage = 'JWTアルゴリズムの不一致: 期待値 {0}、実際の値 {1}。' + jwtMissingJtiExceptionMessage = "JWT に必要な 'jti' (JWT ID) クレームがありません。" + deprecatedFunctionWarningMessage = "警告: 関数 '{0}' は非推奨であり、今後のバージョンで削除されます。代わりに関数 '{1}' を使用してください。" + unknownAlgorithmOrInvalidPfxExceptionMessage = '未知のアルゴリズムまたは無効なPEMフォーマット。' + unknownAlgorithmWithKeySizeExceptionMessage = '未知の {0} アルゴリズム(キーサイズ: {1} ビット)。' + jwtCertificateAuthNotSupportedExceptionMessage = 'JWT 証明書認証は PowerShell 7.0 以上でのみサポートされています。' + jwtNoExpirationExceptionMessage = "JWTに有効期限がありません。'exp'(有効期限)クレームが必要です。" + bearerTokenAuthMethodNotSupportedExceptionMessage = 'リクエスト本文を使用したBearerトークン認証は、HTTP PUT、POST、PATCHメソッドでのみサポートされています。' + certificateNotValidYetExceptionMessage = '証明書 {0} はまだ有効ではありません。有効開始日: {1} (UTC)' + certificateNotValidForPurposeExceptionMessage = "証明書は '{0}' に対して有効ではありません。検出された目的: {1}" + certificateUnknownEkusStrictModeExceptionMessage = '証明書に不明な EKU が含まれています。厳格モードでは拒否されます。検出された値: {0}' + failedToCreateCertificateRequestExceptionMessage = '証明書リクエストの生成に失敗しました。' + unsupportedCertificateKeyLengthExceptionMessage = 'サポートされていない証明書キー長: {0} ビット。サポートされているキー長を使用してください。' + invalidTypeExceptionMessage = 'エラー: {0} の型が無効です。期待された型 {1} ですが、受け取った型は [{2}] です。' + certificateSignatureInvalidExceptionMessage = '証明書 {0} の署名が無効です。証明書が改ざんされた可能性があるか、信頼できる機関によって署名されていません。' + certificateUntrustedRootExceptionMessage = '証明書 {0} は信頼されていないルート証明機関によって発行されました。ルートCA証明書をインストールするか、信頼できる機関の証明書を使用してください。' + certificateRevokedExceptionMessage = '証明書 {0} は失効しました。理由: {1}。新しい有効な証明書を取得してください。' + certificateExpiredIntermediateExceptionMessage = '証明書 {0} は {1} に有効期限が切れた中間証明書によって署名されています。証明書チェーンは無効です。' + certificateValidationFailedExceptionMessage = '証明書 {0} の検証に失敗しました。証明書チェーンと有効期限を確認してください。' + certificateWeakAlgorithmExceptionMessage = '証明書 {0} は脆弱な暗号アルゴリズム ({1}) を使用しています。SHA-256 以上を使用することを推奨します。' + selfSignedCertificatesNotAllowedExceptionMessage = 'セキュリティ上の制限により、自己署名証明書は許可されていません。' + digestTokenAuthMethodNotSupportedExceptionMessage = "Digest Quality of Protection 'auth-int' は HTTP PUT、POST、PATCH メソッドでのみサポートされます。" + pemCertificateNotSupportedByPwshVersionExceptionMessage = 'PEM 証明書フォーマットは PowerShell {0} ではサポートされていません。別の証明書フォーマットを使用するか、PowerShell 7.0 以降にアップグレードしてください。' } \ No newline at end of file diff --git a/src/Locales/ko/Pode.psd1 b/src/Locales/ko/Pode.psd1 index a0d882588..0043876f4 100644 --- a/src/Locales/ko/Pode.psd1 +++ b/src/Locales/ko/Pode.psd1 @@ -259,7 +259,6 @@ certificateThumbprintsNameSupportedOnWindowsExceptionMessage = '인증서 지문/이름은 Windows에서만 지원됩니다.' sseConnectionNameRequiredExceptionMessage = "-Name 또는 `$WebEvent.Sse.Name에서 SSE 연결 이름이 필요합니다." invalidMiddlewareTypeExceptionMessage = '제공된 미들웨어 중 하나가 잘못된 유형입니다. 예상된 유형은 ScriptBlock 또는 Hashtable이지만, 얻은 것은: {0}' - noSecretForJwtSignatureExceptionMessage = 'JWT 서명을 위한 비밀이 제공되지 않았습니다.' modulePathDoesNotExistExceptionMessage = '모듈 경로가 존재하지 않습니다: {0}' taskAlreadyDefinedExceptionMessage = '[작업] {0}: 작업이 이미 정의되었습니다.' verbAlreadyDefinedExceptionMessage = '[동사] {0}: 이미 정의되었습니다.' @@ -326,4 +325,35 @@ rateLimitRuleDoesNotExistExceptionMessage = "이름이 '{0}'인 비율 제한 규칙이 존재하지 않습니다." accessLimitRuleAlreadyExistsExceptionMessage = "이름이 '{0}'인 액세스 제한 규칙이 이미 존재합니다." accessLimitRuleDoesNotExistExceptionMessage = "이름이 '{0}'인 액세스 제한 규칙이 존재하지 않습니다." + missingKeyForAlgorithmExceptionMessage = '{1} 알고리즘 ({2}) 에는 {0} 키가 필요합니다.' + jwtIssuedInFutureExceptionMessage = "JWT의 'iat' (발행 시간) 타임스탬프가 미래로 설정되어 있습니다. 토큰은 아직 유효하지 않습니다." + jwtInvalidIssuerExceptionMessage = "JWT의 'iss' (발행자) 클레임이 잘못되었거나 누락되었습니다. 예상 발행자: '{0}'." + jwtMissingIssuerExceptionMessage = "JWT에 필요한 'iss' (발행자) 클레임이 없습니다. 유효한 발행자가 필요합니다." + jwtInvalidAudienceExceptionMessage = "JWT의 'aud' (대상) 클레임이 잘못되었거나 누락되었습니다. 예상 대상: '{0}'." + jwtMissingAudienceExceptionMessage = "JWT에 필요한 'aud' (대상) 클레임이 없습니다. 유효한 대상이 필요합니다." + jwtInvalidSubjectExceptionMessage = "JWT의 'sub' (주체) 클레임이 잘못되었거나 누락되었습니다. 유효한 주체가 필요합니다." + jwtInvalidJtiExceptionMessage = "JWT의 'jti' (JWT ID) 클레임이 잘못되었거나 누락되었습니다. 유효한 고유 식별자가 필요합니다." + jwtAlgorithmMismatchExceptionMessage = 'JWT 알고리즘 불일치: 예상 {0}, 발견된 {1}.' + jwtMissingJtiExceptionMessage = "JWT에 필요한 'jti' (JWT ID) 클레임이 없습니다." + deprecatedFunctionWarningMessage = "경고: 함수 '{0}'는 더 이상 지원되지 않으며 향후 버전에서 제거될 예정입니다. 대신 '{1}' 함수를 사용하십시오." + unknownAlgorithmOrInvalidPfxExceptionMessage = '알 수 없는 알고리즘 또는 잘못된 PEM 형식입니다.' + unknownAlgorithmWithKeySizeExceptionMessage = '알 수 없는 {0} 알고리즘 (키 크기: {1} 비트).' + jwtCertificateAuthNotSupportedExceptionMessage = 'JWT 인증서 인증은 PowerShell 7.0 이상에서만 지원됩니다.' + jwtNoExpirationExceptionMessage = "JWT에 'exp' (만료 시간) 클레임이 없습니다. 유효한 만료 시간이 필요합니다." + bearerTokenAuthMethodNotSupportedExceptionMessage = '요청 본문을 사용한 Bearer 토큰 인증은 HTTP PUT, POST, PATCH 메서드에서만 지원됩니다.' + certificateNotValidYetExceptionMessage = '인증서 {0}는 아직 유효하지 않습니다. 유효 기간 시작: {1} (UTC)' + certificateNotValidForPurposeExceptionMessage = "인증서는 '{0}'에 대해 유효하지 않습니다. 발견된 용도: {1}" + certificateUnknownEkusStrictModeExceptionMessage = '인증서에 알 수 없는 EKU가 포함되어 있습니다. 엄격한 모드에서 거부되었습니다. 발견된 값: {0}' + failedToCreateCertificateRequestExceptionMessage = '인증서 요청 생성에 실패했습니다.' + unsupportedCertificateKeyLengthExceptionMessage = '지원되지 않는 인증서 키 길이: {0}비트. 지원되는 키 길이를 사용하세요.' + invalidTypeExceptionMessage = '오류: {0}의 타입이 잘못되었습니다. 예상된 타입: {1}, 받은 타입: [{2}].' + certificateSignatureInvalidExceptionMessage = '인증서 {0}의 서명이 잘못되었습니다. 인증서가 변조되었거나 신뢰할 수 있는 기관에서 서명되지 않았을 수 있습니다.' + certificateUntrustedRootExceptionMessage = '인증서 {0}는 신뢰할 수 없는 루트에서 발급되었습니다. 루트 CA 인증서를 설치하거나 신뢰할 수 있는 기관의 인증서를 사용하세요.' + certificateRevokedExceptionMessage = '인증서 {0}가 폐기되었습니다. 사유: {1}. 새로운 유효한 인증서를 얻으세요.' + certificateExpiredIntermediateExceptionMessage = '인증서 {0}는 {1}에 만료된 중간 인증서에 의해 서명되었습니다. 인증서 체인이 더 이상 유효하지 않습니다.' + certificateValidationFailedExceptionMessage = '인증서 {0}의 검증이 실패했습니다. 인증서 체인과 유효 기간을 확인하세요.' + certificateWeakAlgorithmExceptionMessage = '인증서 {0}는 약한 암호화 알고리즘 ({1})을 사용합니다. SHA-256 이상의 강력한 알고리즘을 사용하는 것이 권장됩니다.' + selfSignedCertificatesNotAllowedExceptionMessage = '보안 제한으로 인해 자체 서명된 인증서는 허용되지 않습니다.' + digestTokenAuthMethodNotSupportedExceptionMessage = "Digest Quality of Protection 'auth-int'는 HTTP PUT, POST, PATCH 메서드에서만 지원됩니다." + pemCertificateNotSupportedByPwshVersionExceptionMessage = 'PEM 인증서 형식은 PowerShell {0}에서 지원되지 않습니다. 다른 인증서 형식을 사용하거나 PowerShell 7.0 이상으로 업그레이드하세요.' } \ No newline at end of file diff --git a/src/Locales/nl/Pode.psd1 b/src/Locales/nl/Pode.psd1 index 6aad6ebc4..ebbf4ee98 100644 --- a/src/Locales/nl/Pode.psd1 +++ b/src/Locales/nl/Pode.psd1 @@ -259,7 +259,6 @@ certificateThumbprintsNameSupportedOnWindowsExceptionMessage = 'Certificaat thumbprints/naam worden alleen ondersteund op Windows OS.' sseConnectionNameRequiredExceptionMessage = "Een SSE-verbindingnaam is vereist, hetzij van -Naam of `$WebEvent.Sse.Name" invalidMiddlewareTypeExceptionMessage = 'Een van de opgegeven middlewares is van een ongeldig type. Verwachte ScriptBlock of Hashtable, maar kreeg: {0}' - noSecretForJwtSignatureExceptionMessage = 'Geen geheim opgegeven voor JWT-handtekening.' modulePathDoesNotExistExceptionMessage = 'Het modulepad bestaat niet: {0}' taskAlreadyDefinedExceptionMessage = '[Taak] {0}: Taak al gedefinieerd.' verbAlreadyDefinedExceptionMessage = '[Werkwoord] {0}: Al gedefinieerd' @@ -326,4 +325,35 @@ rateLimitRuleDoesNotExistExceptionMessage = "Rate Limit-regel met de naam '{0}' bestaat niet." accessLimitRuleAlreadyExistsExceptionMessage = "Toegangslimietregel met de naam '{0}' bestaat al." accessLimitRuleDoesNotExistExceptionMessage = "Toegangslimietregel met de naam '{0}' bestaat niet." + missingKeyForAlgorithmExceptionMessage = 'Een {0}-sleutel is vereist voor {1}-algoritmen ({2}).' + jwtIssuedInFutureExceptionMessage = "De 'iat' (Issued At) tijdstempel van de JWT is ingesteld in de toekomst. Het token is nog niet geldig." + jwtInvalidIssuerExceptionMessage = "De JWT 'iss' (Issuer) claim is ongeldig of ontbreekt. Verwachte uitgever: '{0}'." + jwtMissingIssuerExceptionMessage = "De JWT mist de vereiste 'iss' (Issuer) claim. Een geldige uitgever is vereist." + jwtInvalidAudienceExceptionMessage = "De JWT 'aud' (Audience) claim is ongeldig of ontbreekt. Verwacht publiek: '{0}'." + jwtMissingAudienceExceptionMessage = "De JWT mist de vereiste 'aud' (Audience) claim. Een geldig publiek is vereist." + jwtInvalidSubjectExceptionMessage = "De JWT 'sub' (Subject) claim is ongeldig of ontbreekt. Een geldig subject is vereist." + jwtInvalidJtiExceptionMessage = "De JWT 'jti' (JWT ID) claim is ongeldig of ontbreekt. Een geldige unieke identificatie is vereist." + jwtAlgorithmMismatchExceptionMessage = 'JWT-algoritme komt niet overeen: Verwacht {0}, gevonden {1}.' + jwtMissingJtiExceptionMessage = "De JWT mist de vereiste 'jti' (JWT ID) claim." + deprecatedFunctionWarningMessage = "WAARSCHUWING: De functie '{0}' is verouderd en zal in toekomstige versies worden verwijderd. Gebruik in plaats daarvan de functie '{1}'." + unknownAlgorithmOrInvalidPfxExceptionMessage = 'Onbekend algoritme of ongeldig PEM-formaat.' + unknownAlgorithmWithKeySizeExceptionMessage = 'Onbekend {0}-algoritme (Sleutelgrootte: {1} bits).' + jwtCertificateAuthNotSupportedExceptionMessage = 'JWT-certificaatverificatie wordt alleen ondersteund in PowerShell 7.0 of hoger.' + jwtNoExpirationExceptionMessage = "De JWT mist de vereiste 'exp' (Expiration) claim. Een geldige vervaldatum is vereist." + bearerTokenAuthMethodNotSupportedExceptionMessage = 'Bearer-tokenverificatie met de aanvraagtekst wordt alleen ondersteund met HTTP PUT-, POST- of PATCH-methoden.' + certificateNotValidYetExceptionMessage = 'Certificaat {0} is NOG niet geldig. Geldig vanaf: {1} (UTC)' + certificateNotValidForPurposeExceptionMessage = "Certificaat is NIET geldig voor '{0}'. Gevonden doeleinden: {1}" + certificateUnknownEkusStrictModeExceptionMessage = "Certificaat bevat onbekende EKU's. Strikte modus wijst het af. Gevonden: {0}" + failedToCreateCertificateRequestExceptionMessage = 'Kan geen certificaataanvraag genereren.' + unsupportedCertificateKeyLengthExceptionMessage = 'Niet-ondersteunde certificaatsleutellengte: {0} bits. Gebruik een ondersteunde sleutellengte.' + invalidTypeExceptionMessage = 'Fout: Ongeldig type voor {0}. Verwacht {1}, maar ontvangen [{2}].' + certificateSignatureInvalidExceptionMessage = 'Het certificaat {0} heeft een ongeldige handtekening. Het certificaat kan zijn gewijzigd of is niet ondertekend door een vertrouwde autoriteit.' + certificateUntrustedRootExceptionMessage = 'Het certificaat {0} is uitgegeven door een niet-vertrouwde root. Installeer het root-CA-certificaat of gebruik een certificaat van een vertrouwde autoriteit.' + certificateRevokedExceptionMessage = 'Het certificaat {0} is ingetrokken. Reden: {1}. Verkrijg een nieuw geldig certificaat.' + certificateExpiredIntermediateExceptionMessage = 'Het certificaat {0} is ondertekend door een tussenliggend certificaat dat op {1} is verlopen. De certificaatketen is niet langer geldig.' + certificateValidationFailedExceptionMessage = 'Certificaatvalidatie mislukt voor {0}. Controleer de certificaatketen en de geldigheidsperiode.' + certificateWeakAlgorithmExceptionMessage = 'Het certificaat {0} gebruikt een zwak cryptografisch algoritme: {1}. Het wordt aanbevolen om SHA-256 of sterker te gebruiken.' + selfSignedCertificatesNotAllowedExceptionMessage = 'Zelfondertekende certificaten zijn niet toegestaan vanwege beveiligingsbeperkingen.' + digestTokenAuthMethodNotSupportedExceptionMessage = "Digest Quality of Protection 'auth-int' wordt alleen ondersteund voor HTTP PUT-, POST- of PATCH-methoden." + pemCertificateNotSupportedByPwshVersionExceptionMessage = 'Het PEM-certificaatformaat wordt niet ondersteund in PowerShell {0}. Gebruik een ander certificaatformaat of upgrade naar PowerShell 7.0 of later.' } \ No newline at end of file diff --git a/src/Locales/pl/Pode.psd1 b/src/Locales/pl/Pode.psd1 index cf5dd507b..02dbb289d 100644 --- a/src/Locales/pl/Pode.psd1 +++ b/src/Locales/pl/Pode.psd1 @@ -259,7 +259,6 @@ certificateThumbprintsNameSupportedOnWindowsExceptionMessage = 'Odciski palców/nazwa certyfikatu są obsługiwane tylko w systemie Windows.' sseConnectionNameRequiredExceptionMessage = "Wymagana jest nazwa połączenia SSE, z -Name lub `$WebEvent.Sse.Name" invalidMiddlewareTypeExceptionMessage = 'Jeden z dostarczonych Middleware jest nieprawidłowego typu. Oczekiwano ScriptBlock lub Hashtable, ale otrzymano: {0}' - noSecretForJwtSignatureExceptionMessage = 'Nie podano tajemnicy dla podpisu JWT.' modulePathDoesNotExistExceptionMessage = 'Ścieżka modułu nie istnieje: {0}' taskAlreadyDefinedExceptionMessage = '[Zadanie] {0}: Zadanie już zdefiniowane.' verbAlreadyDefinedExceptionMessage = '[Czasownik] {0}: Już zdefiniowane' @@ -326,4 +325,35 @@ rateLimitRuleDoesNotExistExceptionMessage = "Reguła limitu szybkości o nazwie '{0}' nie istnieje." accessLimitRuleAlreadyExistsExceptionMessage = "Reguła limitu dostępu o nazwie '{0}' już istnieje." accessLimitRuleDoesNotExistExceptionMessage = "Reguła limitu dostępu o nazwie '{0}' nie istnieje." + missingKeyForAlgorithmExceptionMessage = 'Klucz {0} jest wymagany dla algorytmów {1} ({2}).' + jwtIssuedInFutureExceptionMessage = "Znacznik czasu 'iat' (Issued At) w JWT jest ustawiony w przyszłości. Token nie jest jeszcze ważny." + jwtInvalidIssuerExceptionMessage = "Pole 'iss' (Issuer) w JWT jest nieprawidłowe lub nieobecne. Oczekiwany wydawca: '{0}'." + jwtMissingIssuerExceptionMessage = "JWT nie zawiera wymaganego pola 'iss' (Issuer). Wymagany jest prawidłowy wydawca." + jwtInvalidAudienceExceptionMessage = "Pole 'aud' (Audience) w JWT jest nieprawidłowe lub nieobecne. Oczekiwana publiczność: '{0}'." + jwtMissingAudienceExceptionMessage = "JWT nie zawiera wymaganego pola 'aud' (Audience). Wymagana jest prawidłowa publiczność." + jwtInvalidSubjectExceptionMessage = "Pole 'sub' (Subject) w JWT jest nieprawidłowe lub nieobecne. Wymagany jest prawidłowy podmiot." + jwtInvalidJtiExceptionMessage = "Pole 'jti' (JWT ID) w JWT jest nieprawidłowe lub nieobecne. Wymagany jest prawidłowy unikalny identyfikator." + jwtAlgorithmMismatchExceptionMessage = 'Niezgodność algorytmu JWT: Oczekiwano {0}, znaleziono {1}.' + jwtMissingJtiExceptionMessage = "JWT nie zawiera wymaganego pola 'jti' (JWT ID)." + deprecatedFunctionWarningMessage = "OSTRZEŻENIE: Funkcja '{0}' jest przestarzała i zostanie usunięta w przyszłych wersjach. Proszę użyć funkcji '{1}' zamiast niej." + unknownAlgorithmOrInvalidPfxExceptionMessage = 'Nieznany algorytm lub nieprawidłowy format PEM.' + unknownAlgorithmWithKeySizeExceptionMessage = 'Nieznany algorytm {0} (Rozmiar klucza: {1} bitów).' + jwtCertificateAuthNotSupportedExceptionMessage = 'Uwierzytelnianie certyfikatem JWT jest obsługiwane tylko w PowerShell 7.0 lub nowszym.' + jwtNoExpirationExceptionMessage = "JWT nie zawiera wymaganego pola 'exp' (Expiration). Wymagany jest prawidłowy czas wygaśnięcia." + bearerTokenAuthMethodNotSupportedExceptionMessage = 'Uwierzytelnianie tokenem Bearer za pomocą treści żądania jest obsługiwane tylko dla metod HTTP PUT, POST lub PATCH.' + certificateNotValidYetExceptionMessage = 'Certyfikat {0} NIE jest jeszcze ważny. Ważny od: {1} (UTC)' + certificateNotValidForPurposeExceptionMessage = "Certyfikat NIE jest ważny dla '{0}'. Znalezione przeznaczenia: {1}" + certificateUnknownEkusStrictModeExceptionMessage = 'Certyfikat zawiera nieznane EKU. Tryb ścisły go odrzuca. Znaleziono: {0}' + failedToCreateCertificateRequestExceptionMessage = 'Nie udało się wygenerować żądania certyfikatu.' + unsupportedCertificateKeyLengthExceptionMessage = 'Nieobsługiwana długość klucza certyfikatu: {0} bitów. Proszę użyć obsługiwanej długości klucza.' + invalidTypeExceptionMessage = 'Błąd: Nieprawidłowy typ dla {0}. Oczekiwano {1}, ale otrzymano [{2}].' + certificateSignatureInvalidExceptionMessage = 'Certyfikat {0} ma nieprawidłowy podpis. Może zostać sfałszowany lub nie został podpisany przez zaufany urząd certyfikacji.' + certificateUntrustedRootExceptionMessage = 'Certyfikat {0} został wydany przez niezaufanego dostawcę. Zainstaluj certyfikat główny CA lub użyj certyfikatu od zaufanego dostawcy.' + certificateRevokedExceptionMessage = 'Certyfikat {0} został unieważniony. Powód: {1}. Proszę uzyskać nowy ważny certyfikat.' + certificateExpiredIntermediateExceptionMessage = 'Certyfikat {0} został podpisany przez certyfikat pośredni, który wygasł {1}. Łańcuch certyfikatów nie jest już ważny.' + certificateValidationFailedExceptionMessage = 'Weryfikacja certyfikatu dla {0} nie powiodła się. Sprawdź łańcuch certyfikatów i okres ważności.' + certificateWeakAlgorithmExceptionMessage = 'Certyfikat {0} używa słabego algorytmu kryptograficznego: {1}. Zaleca się użycie SHA-256 lub silniejszego.' + selfSignedCertificatesNotAllowedExceptionMessage = 'Certyfikaty samopodpisane nie są dozwolone ze względu na ograniczenia bezpieczeństwa.' + digestTokenAuthMethodNotSupportedExceptionMessage = "Digest Quality of Protection 'auth-int' jest obsługiwany tylko dla metod HTTP PUT, POST lub PATCH." + pemCertificateNotSupportedByPwshVersionExceptionMessage = 'Format certyfikatu PEM nie jest obsługiwany w PowerShell {0}. Użyj innego formatu certyfikatu lub zaktualizuj do PowerShell 7.0 lub nowszego.' } \ No newline at end of file diff --git a/src/Locales/pt/Pode.psd1 b/src/Locales/pt/Pode.psd1 index b8e1e4cd1..c3dae620f 100644 --- a/src/Locales/pt/Pode.psd1 +++ b/src/Locales/pt/Pode.psd1 @@ -259,7 +259,6 @@ certificateThumbprintsNameSupportedOnWindowsExceptionMessage = 'Impressões digitais/nome do certificado são suportados apenas no Windows.' sseConnectionNameRequiredExceptionMessage = "Um nome de conexão SSE é necessário, seja de -Name ou `$WebEvent.Sse.Name." invalidMiddlewareTypeExceptionMessage = 'Um dos Middlewares fornecidos é de um tipo inválido. Esperado ScriptBlock ou Hashtable, mas obtido: {0}' - noSecretForJwtSignatureExceptionMessage = 'Nenhum segredo fornecido para a assinatura JWT.' modulePathDoesNotExistExceptionMessage = 'O caminho do módulo não existe: {0}' taskAlreadyDefinedExceptionMessage = '[Tarefa] {0}: Tarefa já definida.' verbAlreadyDefinedExceptionMessage = '[Verbo] {0}: Já definido' @@ -326,4 +325,35 @@ rateLimitRuleDoesNotExistExceptionMessage = "A regra de limite de taxa com o nome '{0}' não existe." accessLimitRuleAlreadyExistsExceptionMessage = "A regra de limite de acesso com o nome '{0}' já existe." accessLimitRuleDoesNotExistExceptionMessage = "A regra de limite de acesso com o nome '{0}' não existe." + missingKeyForAlgorithmExceptionMessage = 'Uma chave {0} é necessária para os algoritmos {1} ({2}).' + jwtIssuedInFutureExceptionMessage = "O carimbo de data/hora 'iat' (Emitido em) do JWT está definido para o futuro. O token ainda não é válido." + jwtInvalidIssuerExceptionMessage = "A reivindicação 'iss' (Issuer) do JWT é inválida ou está ausente. Emissor esperado: '{0}'." + jwtMissingIssuerExceptionMessage = "O JWT está sem a reivindicação obrigatória 'iss' (Issuer). Um emissor válido é necessário." + jwtInvalidAudienceExceptionMessage = "A reivindicação 'aud' (Audience) do JWT é inválida ou está ausente. Audiência esperada: '{0}'." + jwtMissingAudienceExceptionMessage = "O JWT está sem a reivindicação obrigatória 'aud' (Audience). Uma audiência válida é necessária." + jwtInvalidSubjectExceptionMessage = "A reivindicação 'sub' (Subject) do JWT é inválida ou está ausente. Um sujeito válido é necessário." + jwtInvalidJtiExceptionMessage = "A reivindicação 'jti' (JWT ID) do JWT é inválida ou está ausente. Um identificador único válido é necessário." + jwtAlgorithmMismatchExceptionMessage = 'Incompatibilidade de algoritmo JWT: Esperado {0}, encontrado {1}.' + jwtMissingJtiExceptionMessage = "O JWT está sem a reivindicação obrigatória 'jti' (JWT ID)." + deprecatedFunctionWarningMessage = "警告: 函数 '{0}' 已被弃用,并将在未来版本中移除。请改用函数 '{1}'。" + unknownAlgorithmOrInvalidPfxExceptionMessage = 'Algoritmo desconhecido ou formato PEM inválido.' + unknownAlgorithmWithKeySizeExceptionMessage = 'Algoritmo {0} desconhecido (Tamanho da chave: {1} bits).' + jwtCertificateAuthNotSupportedExceptionMessage = 'A autenticação de certificado JWT é suportada apenas no PowerShell 7.0 ou superior.' + jwtNoExpirationExceptionMessage = "O JWT está sem a reivindicação obrigatória 'exp' (Expiração)." + bearerTokenAuthMethodNotSupportedExceptionMessage = 'A autenticação com token Bearer usando o corpo da solicitação é suportada apenas nos métodos HTTP PUT, POST ou PATCH.' + certificateNotValidYetExceptionMessage = 'O certificado {0} AINDA NÃO é válido. Válido a partir de: {1} (UTC)' + certificateNotValidForPurposeExceptionMessage = "O certificado NÃO é válido para '{0}'. Finalidades encontradas: {1}" + certificateUnknownEkusStrictModeExceptionMessage = 'O certificado contém EKUs desconhecidos. O modo estrito o rejeita. Encontrado: {0}' + failedToCreateCertificateRequestExceptionMessage = 'Falha ao gerar a solicitação de certificado.' + unsupportedCertificateKeyLengthExceptionMessage = 'Comprimento da chave do certificado não suportado: {0} bits. Use um comprimento de chave suportado.' + invalidTypeExceptionMessage = 'Erro: Tipo inválido para {0}. Esperado {1}, mas recebido [{2}].' + certificateSignatureInvalidExceptionMessage = 'O certificado {0} possui uma assinatura inválida. O certificado pode ter sido adulterado ou não foi assinado por uma autoridade confiável.' + certificateUntrustedRootExceptionMessage = 'O certificado {0} foi emitido por uma raiz não confiável. Instale o certificado da CA raiz ou use um certificado de uma autoridade confiável.' + certificateRevokedExceptionMessage = 'O certificado {0} foi revogado. Motivo: {1}. Por favor, obtenha um novo certificado válido.' + certificateExpiredIntermediateExceptionMessage = 'O certificado {0} foi assinado por um certificado intermediário que expirou em {1}. A cadeia de certificação não é mais válida.' + certificateValidationFailedExceptionMessage = 'Falha na validação do certificado para {0}. Verifique a cadeia de certificação e o período de validade.' + certificateWeakAlgorithmExceptionMessage = 'O certificado {0} usa um algoritmo criptográfico fraco: {1}. É recomendado o uso de SHA-256 ou superior.' + selfSignedCertificatesNotAllowedExceptionMessage = 'Certificados autoassinados não são permitidos devido a restrições de segurança.' + digestTokenAuthMethodNotSupportedExceptionMessage = "Digest Quality of Protection 'auth-int' é suportado apenas para os métodos HTTP PUT, POST ou PATCH." + pemCertificateNotSupportedByPwshVersionExceptionMessage = 'O formato de certificado PEM não é suportado no PowerShell {0}. Use um formato de certificado diferente ou atualize para o PowerShell 7.0 ou posterior.' } \ No newline at end of file diff --git a/src/Locales/zh/Pode.psd1 b/src/Locales/zh/Pode.psd1 index 3653dc151..af4aee90f 100644 --- a/src/Locales/zh/Pode.psd1 +++ b/src/Locales/zh/Pode.psd1 @@ -259,7 +259,6 @@ certificateThumbprintsNameSupportedOnWindowsExceptionMessage = '证书指纹/名称仅在 Windows 上受支持。' sseConnectionNameRequiredExceptionMessage = "需要SSE连接名称, 可以从-Name或`$WebEvent.Sse.Name获取。" invalidMiddlewareTypeExceptionMessage = '提供的中间件之一是无效的类型。期望是 ScriptBlock 或 Hashtable, 但得到了: {0}' - noSecretForJwtSignatureExceptionMessage = '未提供 JWT 签名的密钥。' modulePathDoesNotExistExceptionMessage = '模块路径不存在: {0}' taskAlreadyDefinedExceptionMessage = '[任务] {0}: 任务已定义。' verbAlreadyDefinedExceptionMessage = '[Verb] {0}: 已经定义' @@ -326,4 +325,35 @@ rateLimitRuleDoesNotExistExceptionMessage = '速率限制规则不存在: {0}' accessLimitRuleAlreadyExistsExceptionMessage = '访问限制规则已存在: {0}' accessLimitRuleDoesNotExistExceptionMessage = '访问限制规则不存在: {0}' + missingKeyForAlgorithmExceptionMessage = 'Uma chave {0} é necessária para os algoritmos {1} ({2}).' + jwtIssuedInFutureExceptionMessage = "JWT 的 'iat' (签发时间) 时间戳设置在未来。该令牌尚未生效。" + jwtInvalidIssuerExceptionMessage = "JWT 的 'iss' (发行者) 声明无效或缺失。预期发行者: '{0}'。" + jwtMissingIssuerExceptionMessage = "JWT 缺少必要的 'iss' (发行者) 声明。必须提供有效的发行者。" + jwtInvalidAudienceExceptionMessage = "JWT 的 'aud' (受众) 声明无效或缺失。预期受众: '{0}'。" + jwtMissingAudienceExceptionMessage = "JWT 缺少必要的 'aud' (受众) 声明。必须提供有效的受众。" + jwtInvalidSubjectExceptionMessage = "JWT 的 'sub' (主题) 声明无效或缺失。必须提供有效的主题。" + jwtInvalidJtiExceptionMessage = "JWT 的 'jti' (JWT ID) 声明无效或缺失。必须提供有效的唯一标识符。" + jwtAlgorithmMismatchExceptionMessage = 'JWT 算法不匹配: 预期 {0},实际 {1}。' + jwtMissingJtiExceptionMessage = "JWT 缺少必要的 'jti' (JWT ID) 声明。" + deprecatedFunctionWarningMessage = "警告: 函数 '{0}' 已被弃用,并将在未来版本中移除。请改用函数 '{1}'。" + unknownAlgorithmOrInvalidPfxExceptionMessage = '未知算法或无效的 PEM 格式。' + unknownAlgorithmWithKeySizeExceptionMessage = '未知 {0} 算法(密钥大小: {1} 位)。' + jwtCertificateAuthNotSupportedExceptionMessage = 'JWT 证书身份验证仅支持 PowerShell 7.0 或更高版本。' + jwtNoExpirationExceptionMessage = "JWT 缺少必要的 'exp' (到期时间) 声明。必须提供有效的到期时间。" + bearerTokenAuthMethodNotSupportedExceptionMessage = '使用请求正文进行Bearer令牌认证仅支持HTTP PUT、POST或PATCH方法。' + certificateNotValidYetExceptionMessage = '证书 {0} 仍然无效。有效期开始: {1} (UTC)' + certificateNotValidForPurposeExceptionMessage = "证书对 '{0}' 无效。发现的用途: {1}" + certificateUnknownEkusStrictModeExceptionMessage = '证书包含未知的 EKU。严格模式拒绝它。发现: {0}' + failedToCreateCertificateRequestExceptionMessage = '生成证书请求失败。' + unsupportedCertificateKeyLengthExceptionMessage = '不支持的证书密钥长度: {0} 位。请使用受支持的密钥长度。' + invalidTypeExceptionMessage = '错误: {0} 的类型无效。期望 {1},但收到 [{2}]。' + certificateSignatureInvalidExceptionMessage = '证书 {0} 的签名无效。证书可能已被篡改,或未由受信任的机构签署。' + certificateUntrustedRootExceptionMessage = '证书 {0} 由不受信任的根证书颁发。请安装根 CA 证书或使用受信任机构的证书。' + certificateRevokedExceptionMessage = '证书 {0} 已被吊销。原因: {1}。请获取新的有效证书。' + certificateExpiredIntermediateExceptionMessage = '证书 {0} 由 {1} 过期的中间证书签署。证书链已失效。' + certificateValidationFailedExceptionMessage = '证书 {0} 的验证失败。请检查证书链和有效期。' + certificateWeakAlgorithmExceptionMessage = '证书 {0} 使用了弱加密算法: {1}。建议使用 SHA-256 或更强的算法。' + selfSignedCertificatesNotAllowedExceptionMessage = '由于安全限制,不允许使用自签名证书。' + digestTokenAuthMethodNotSupportedExceptionMessage = "Digest Quality of Protection 'auth-int' 仅支持 HTTP PUT、POST 或 PATCH 方法。" + pemCertificateNotSupportedByPwshVersionExceptionMessage = 'PEM 证书格式在 PowerShell {0} 中不受支持。请使用其他证书格式或升级到 PowerShell 7.0 或更高版本。' } \ No newline at end of file diff --git a/src/Pode.psd1 b/src/Pode.psd1 index b520f389c..baaa97b4e 100644 --- a/src/Pode.psd1 +++ b/src/Pode.psd1 @@ -256,9 +256,6 @@ 'Add-PodeAuthMiddleware', 'Add-PodeAuthIIS', 'Add-PodeAuthUserFile', - 'ConvertTo-PodeJwt', - 'ConvertFrom-PodeJwt', - 'Test-PodeJwt' 'Use-PodeAuth', 'ConvertFrom-PodeOIDCDiscovery', 'Test-PodeAuthUser', @@ -268,6 +265,22 @@ 'Get-PodeAuthUser', 'Add-PodeAuthSession', 'New-PodeAuthKeyTab', + 'New-PodeAuthBearerScheme', + 'New-PodeAuthDigestScheme', + + #JWT + 'ConvertTo-PodeJwt', + 'ConvertFrom-PodeJwt', + 'Test-PodeJwt', + 'Update-PodeJwt', + + #Certificate + 'Export-PodeCertificate', + 'Get-PodeCertificatePurpose', + 'Import-PodeCertificate', + 'New-PodeCertificateRequest', + 'New-PodeSelfSignedCertificate', + 'Test-PodeCertificate', # access 'New-PodeAccessScheme', diff --git a/src/Private/ADAuthentication.ps1 b/src/Private/ADAuthentication.ps1 new file mode 100644 index 000000000..c4aea6c09 --- /dev/null +++ b/src/Private/ADAuthentication.ps1 @@ -0,0 +1,749 @@ + + +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 + } +} + +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 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 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' +} diff --git a/src/Private/Authentication.ps1 b/src/Private/Authentication.ps1 index c8331de65..15ec10fe8 100644 --- a/src/Private/Authentication.ps1 +++ b/src/Private/Authentication.ps1 @@ -1,29 +1,81 @@ function Get-PodeAuthBasicType { + <# + .SYNOPSIS + Processes Basic Authentication from the Authorization header. + + .DESCRIPTION + The `Get-PodeAuthBasicType` function extracts and validates the Basic Authorization header + from an HTTP request. It verifies the header format, decodes Base64 credentials, + and returns the extracted username and password. If any validation step fails, + an appropriate HTTP response code and challenge are returned. + + .PARAMETER options + A hashtable containing options for processing the authentication: + - `HeaderTag` [string]: Expected header prefix (e.g., "Basic"). + - `Encoding` [string]: Character encoding for decoding the credentials (default: UTF-8). + - `AsCredential` [bool]: If true, returns credentials as a [PSCredential] object. + + .OUTPUTS + [array] + Returns an array containing the extracted username and password. + If `AsCredential` is set to `$true`, returns a `[PSCredential]` object. + + .EXAMPLE + $options = @{ HeaderTag = 'Basic'; Encoding = 'UTF-8'; AsCredential = $false } + $result = Get-PodeAuthBasicType -options $options + + Returns: + @('username', 'password') + + .EXAMPLE + $options = @{ HeaderTag = 'Basic'; Encoding = 'UTF-8'; AsCredential = $true } + $result = Get-PodeAuthBasicType -options $options + + Returns: + [PSCredential] object containing username and password. + + .NOTES + This function is internal to Pode and subject to change in future releases. + + Possible response codes: + - 401 Unauthorized: When the Authorization header is missing. + - 400 Bad Request: For invalid format, encoding, or credential issues. + + Challenge responses include the following error types: + - `invalid_request` for missing or incorrectly formatted headers. + - `invalid_token` for improperly encoded or malformed credentials. + #> return { param($options) # get the auth header $header = (Get-PodeHeader -Name 'Authorization') if ($null -eq $header) { + $message = 'No Authorization header found' return @{ - Message = 'No Authorization header found' - Code = 401 + Message = $message + Challenge = (New-PodeAuthChallenge -ErrorType invalid_request -ErrorDescription $message) + Code = 401 } } # ensure the first atom is basic (or opt override) $atoms = $header -isplit '\s+' if ($atoms.Length -lt 2) { + $message = 'Invalid Authorization header format' return @{ - Message = 'Invalid Authorization header' - Code = 400 + Message = $message + Challenge = (New-PodeAuthChallenge -ErrorType invalid_request -ErrorDescription $message) + Code = 400 } } if ($atoms[0] -ine $options.HeaderTag) { + $message = "Header is not for $($options.HeaderTag) Authorization" return @{ - Message = "Header is not for $($options.HeaderTag) Authorization" - Code = 400 + Message = $message + Challenge = (New-PodeAuthChallenge -ErrorType invalid_request -ErrorDescription $message) + Code = 400 } } @@ -32,9 +84,11 @@ function Get-PodeAuthBasicType { $enc = [System.Text.Encoding]::GetEncoding($options.Encoding) } catch { + $message = 'Invalid encoding specified for Authorization' return @{ - Message = 'Invalid encoding specified for Authorization' - Code = 400 + Message = $message + Challenge = (New-PodeAuthChallenge -ErrorType invalid_request -ErrorDescription $message) + Code = 400 } } @@ -42,9 +96,22 @@ function Get-PodeAuthBasicType { $decoded = $enc.GetString([System.Convert]::FromBase64String($atoms[1])) } catch { + $message = 'Invalid Base64 string found in Authorization header' return @{ - Message = 'Invalid Base64 string found in Authorization header' - Code = 400 + Message = $message + Challenge = (New-PodeAuthChallenge -ErrorType invalid_token -ErrorDescription $message) + Code = 400 + } + } + + # ensure the decoded string contains a colon separator + $index = $decoded.IndexOf(':') + if ($index -lt 0) { + $message = 'Invalid Authorization credential format' + return @{ + Message = $message + Challenge = (New-PodeAuthChallenge -ErrorType invalid_request -ErrorDescription $message) + Code = 400 } } @@ -68,6 +135,7 @@ function Get-PodeAuthBasicType { } } + function Get-PodeAuthOAuth2Type { return { param($options, $schemes) @@ -282,32 +350,91 @@ function Get-PodeOAuth2RedirectHost { } function Get-PodeAuthClientCertificateType { + <# + .SYNOPSIS + Validates and extracts information from a client certificate in an HTTP request. + + .DESCRIPTION + The `Get-PodeAuthClientCertificateType` function processes the client certificate + from an incoming HTTP request. It validates whether the certificate is supplied, + checks its validity, and ensures it's trusted. If any of these checks fail, + appropriate response codes and challenges are returned. + + .PARAMETER options + A hashtable containing options that can be used to extend the function in the future. + + .OUTPUTS + [array] + Returns an array containing the validated client certificate and any associated errors. + + .EXAMPLE + $options = @{} + $result = Get-PodeAuthClientCertificateType -options $options + + Returns: + An array with the client certificate object and any certificate validation errors. + + .EXAMPLE + $options = @{} + $result = Get-PodeAuthClientCertificateType -options $options + + Example Output: + @($cert, 0) + + .NOTES + This function is internal to Pode and subject to change in future releases. + + Possible response codes: + - 401 Unauthorized: When the client certificate is missing or invalid. + - 403 Forbidden: When the client certificate is untrusted. + + Challenge responses include the following error types: + - `invalid_request`: If no certificate is provided. + - `invalid_token`: If the certificate is invalid, expired, or untrusted. + + #> return { param($options) $cert = $WebEvent.Request.ClientCertificate # ensure we have a client cert if ($null -eq $cert) { + $message = 'No client certificate supplied' return @{ - Message = 'No client certificate supplied' - Code = 401 + Message = $message + Challenge = (New-PodeAuthChallenge -ErrorType invalid_request -ErrorDescription $message) + Code = 401 } } # ensure the cert has a thumbprint if ([string]::IsNullOrWhiteSpace($cert.Thumbprint)) { + $message = 'Invalid client certificate supplied' return @{ - Message = 'Invalid client certificate supplied' - Code = 401 + Message = $message + Challenge = (New-PodeAuthChallenge -ErrorType invalid_token -ErrorDescription $message) + Code = 401 } } # ensure the cert hasn't expired, or has it even started $now = [datetime]::Now if (($cert.NotAfter -lt $now) -or ($cert.NotBefore -gt $now)) { + $message = 'Invalid client certificate supplied (expired or not yet valid)' return @{ - Message = 'Invalid client certificate supplied' - Code = 401 + Message = $message + Challenge = (New-PodeAuthChallenge -ErrorType invalid_token -ErrorDescription $message) + Code = 401 + } + } + + $errors = $WebEvent.Request.ClientCertificateErrors + if ($errors -ne 0) { + $message = 'Untrusted client certificate supplied' + return @{ + Message = $message + Challenge = (New-PodeAuthChallenge -ErrorType invalid_token -ErrorDescription $message) + Code = 403 } } @@ -358,12 +485,43 @@ function Get-PodeAuthNegotiateType { } function Get-PodeAuthApiKeyType { + <# + .SYNOPSIS + Handles API key authentication by retrieving the key from various locations. + + .DESCRIPTION + The `Get-PodeAuthApiKeyType` function extracts and validates API keys + from specified locations such as headers, query parameters, or cookies. + If the API key is found, it is returned as a result; otherwise, + an appropriate authentication challenge is issued. + + .PARAMETER $options + A hashtable containing the following keys: + - `Location`: Specifies where to retrieve the API key from (`header`, `query`, or `cookie`). + - `LocationName`: The name of the header, query parameter, or cookie that holds the API key. + - `AsJWT`: (Optional) If set to `$true`, the function will treat the API key as a JWT token. + - `Secret`: (Required if `AsJWT` is `$true`) The secret used to validate the JWT token. + + .OUTPUTS + [array] + Returns an array containing the extracted API key or JWT payload if authentication is successful. + + .NOTES + The function will return an HTTP 400 response code if the API key is not found. + If `AsJWT` is enabled, the key will be decoded and validated using the provided secret. + The challenge response is formatted to align with authentication best practices. + + Possible HTTP response codes: + - 400 Bad Request: When the API key is missing or JWT validation fails. + + #> return { param($options) - # get api key from appropriate location + # Initialize API key variable $apiKey = [string]::Empty + # Determine API key location and retrieve it switch ($options.Location.ToLowerInvariant()) { 'header' { $apiKey = Get-PodeHeader -Name $options.LocationName @@ -376,38 +534,50 @@ function Get-PodeAuthApiKeyType { 'cookie' { $apiKey = Get-PodeCookieValue -Name $options.LocationName } + default { + $message = "Invalid API key location: $($options.Location)" + return @{ + Message = $message + Challenge = (New-PodeAuthChallenge -ErrorType invalid_request -ErrorDescription $message) + Code = 400 + } + } } - # 400 if no key + # If no API key found, return error if ([string]::IsNullOrWhiteSpace($apiKey)) { + $message = "API key missing in $($options.Location) location: $($options.LocationName)" return @{ - Message = "No $($options.LocationName) $($options.Location) found" - Code = 400 + Message = $message + Challenge = (New-PodeAuthChallenge -ErrorType invalid_request -ErrorDescription $message) + Code = 400 } } - # build the result + # Trim and process the API key $apiKey = $apiKey.Trim() $result = @($apiKey) - # convert as jwt? + # Convert to JWT if required if ($options.AsJWT) { try { - $payload = ConvertFrom-PodeJwt -Token $apiKey -Secret $options.Secret - Test-PodeJwt -Payload $payload + #$payload = ConvertFrom-PodeJwt -Token $apiKey -Secret $options.Secret -Algorithm $options.Algorithm + $result = Confirm-PodeJwt -Token $apiKey -Secret $options.Secret -Algorithm $options.Algorithm + # Test-PodeJwt -Payload $result #-JwtVerificationMode $options.JwtVerificationMode + Test-PodeJwt -Payload $result } catch { if ($_.Exception.Message -ilike '*jwt*') { + $message = "Invalid JWT token: $($_.Exception.Message)" return @{ - Message = $_.Exception.Message - Code = 400 + Message = $message + Challenge = (New-PodeAuthChallenge -ErrorType invalid_request -ErrorDescription $message) + Code = 400 } } throw } - - $result = @($payload) } # return the result @@ -416,167 +586,299 @@ function Get-PodeAuthApiKeyType { } function Get-PodeAuthBearerType { + <# + .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. + - BearerTag: 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 | + #> return { param($options) - # get the auth header + # 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 = 'No Authorization header found' - Challenge = (New-PodeAuthBearerChallenge -Scopes $options.Scopes -ErrorType invalid_request) - Code = 400 + Message = $message + Challenge = New-PodeAuthChallenge -Scopes $options.Scopes -ErrorType invalid_request -ErrorDescription $message + Code = 401 # RFC 6750: Missing credentials should return 401 } } + switch ($options.Location.ToLowerInvariant()) { + 'header' { + # Ensure the first part of the header is 'Bearer' + $atoms = $header -isplit '\s+' - # ensure the first atom is bearer - $atoms = $header -isplit '\s+' - if ($atoms.Length -lt 2) { - return @{ - Message = 'Invalid Authorization header' - Challenge = (New-PodeAuthBearerChallenge -Scopes $options.Scopes -ErrorType invalid_request) - Code = 400 + # 400 Bad Request if no token is provided + $token = $atoms[1] + if ([string]::IsNullOrWhiteSpace($token)) { + $message = 'No Bearer token found' + return @{ + Message = $message + Code = 401 + Challenge = New-PodeAuthChallenge -Scopes $options.Scopes -ErrorType invalid_request -ErrorDescription $message + } + } + + if ($atoms.Length -lt 2) { + $message = 'Invalid Authorization header format' + return @{ + Message = $message + Challenge = (New-PodeAuthChallenge -Scopes $options.Scopes -ErrorType invalid_request -ErrorDescription $message) + Code = 401 # RFC 6750: Invalid credentials format should return 401 + } + } + + if ($atoms[0] -ine $options.BearerTag) { + $message = "Authorization header is not $($options.BearerTag)" + return @{ + Message = $message + Challenge = (New-PodeAuthChallenge -Scopes $options.Scopes -ErrorType invalid_request -ErrorDescription $message) + Code = 401 # RFC 6750: Wrong authentication scheme should return 401 + } + } } - } - if ($atoms[0] -ine $options.HeaderTag) { - return @{ - Message = "Authorization header is not $($options.HeaderTag)" - Challenge = (New-PodeAuthBearerChallenge -Scopes $options.Scopes -ErrorType invalid_request) - Code = 400 + 'query' { + # support RFC6750 + $token = $WebEvent.Query[$options.BearerTag] + if ([string]::IsNullOrWhiteSpace($token)) { + $message = 'No Bearer token found' + return @{ + Message = $message + Code = 400 # RFC 6750: Malformed request should return 400 + Challenge = New-PodeAuthChallenge -Scopes $options.Scopes -ErrorType invalid_request -ErrorDescription $message + } + } } - } - # 400 if no token - $token = $atoms[1] - if ([string]::IsNullOrWhiteSpace($token)) { - return @{ - Message = 'No Bearer token found' - Code = 400 + 'body' { + # support RFC6750 + $token = $WebEvent.Data.($options.BearerTag) + if ([string]::IsNullOrWhiteSpace($token)) { + $message = 'No Bearer token found' + return @{ + Message = $message + Code = 400 # RFC 6750: Malformed request should return 400 + Challenge = New-PodeAuthChallenge -Scopes $options.Scopes -ErrorType invalid_request -ErrorDescription $message + } + } + } + + default { + $message = "Invalid Bearer Token location: $($options.Location)" + return @{ + Message = $message + Challenge = (New-PodeAuthChallenge -ErrorType invalid_request -ErrorDescription $message) + Code = 400 + } } } - # build the result + + # Trim and build the result $token = $token.Trim() - $result = @($token) + #$result = @($token) - # convert as jwt? + # Convert to JWT if required if ($options.AsJWT) { try { - $payload = ConvertFrom-PodeJwt -Token $token -Secret $options.Secret - Test-PodeJwt -Payload $payload + $param = @{ + Token = $token + Secret = $options.Secret + X509Certificate = $options.X509Certificate + Algorithm = $options.Algorithm + } + $result = Confirm-PodeJwt @param + Test-PodeJwt -Payload $result -JwtVerificationMode $options.JwtVerificationMode } catch { if ($_.Exception.Message -ilike '*jwt*') { return @{ - Message = $_.Exception.Message - #https://www.rfc-editor.org/rfc/rfc6750 Bearer token should return 401 - Code = 401 + Message = $_.Exception.Message + Code = 401 # RFC 6750: Invalid token should return 401 + Challenge = New-PodeAuthChallenge -Scopes $options.Scopes -ErrorType invalid_token -ErrorDescription $_.Exception.Message } } throw } - $result = @($payload) } - - # return the result + else { + $result = $token + } + # Return the validated result return $result } } function Get-PodeAuthBearerPostValidator { + <# + .SYNOPSIS + Validates the Bearer token and user authentication. + + .DESCRIPTION + This function processes the Bearer token, checks for the presence of a valid user, + and verifies token scopes against required scopes. It returns appropriate HTTP response codes + as per RFC 6750 (OAuth 2.0 Bearer Token Usage). + + .PARAMETER token + The Bearer token provided by the client. + + .PARAMETER result + The decoded token result containing user and scope information. + + .PARAMETER options + A hashtable containing the following keys: + - Scopes: An array of required scopes for authorization. + + .OUTPUTS + A hashtable containing the following keys based on the validation result: + - Message: Error or success message. + - Code: HTTP response code. + - Challenge: HTTP response challenge in case of errors. + + .NOTES + The function adheres to RFC 6750, which mandates: + - 401 Unauthorized for missing or invalid authentication credentials. + - 403 Forbidden for insufficient scopes. + #> return { param($token, $result, $options) - # if there's no user, fail with challenge + # Validate user presence in the token if (($null -eq $result) -or ($null -eq $result.User)) { + $message = 'User authentication failed' return @{ - Message = 'User not found' - Challenge = (New-PodeAuthBearerChallenge -Scopes $options.Scopes -ErrorType invalid_token) + Message = $message + Challenge = (New-PodeAuthChallenge -Scopes $options.Scopes -ErrorType invalid_token -ErrorDescription $message ) Code = 401 } } - # check for an error and description + # Check for token error and return appropriate response if (![string]::IsNullOrWhiteSpace($result.Error)) { return @{ - Message = 'Authorization failed' - Challenge = (New-PodeAuthBearerChallenge -Scopes $options.Scopes -ErrorType $result.Error -ErrorDescription $result.ErrorDescription) + Message = $result.ErrorDescription + Challenge = (New-PodeAuthChallenge -Scopes $options.Scopes -ErrorType $result.Error -ErrorDescription $result.ErrorDescription) Code = 401 } } - # check the scopes + # Scope validation $hasAuthScopes = (($null -ne $options.Scopes) -and ($options.Scopes.Length -gt 0)) - $hasTokenScope = ![string]::IsNullOrWhiteSpace($result.Scope) + $hasTokenScope = (($null -ne $result.Scope) -and ($result.Scope.Length -gt 0)) - # 403 if we have auth scopes but no token scope + # Return 403 if authorization scopes exist but token lacks scopes if ($hasAuthScopes -and !$hasTokenScope) { + $message = 'Token scope is missing or invalid' return @{ - Message = 'Invalid Scope' - Challenge = (New-PodeAuthBearerChallenge -Scopes $options.Scopes -ErrorType insufficient_scope) + Message = $message + Challenge = (New-PodeAuthChallenge -Scopes $options.Scopes -ErrorType insufficient_scope -ErrorDescription $message ) Code = 403 } } - # 403 if we have both, but token not in auth scope - if ($hasAuthScopes -and $hasTokenScope -and ($options.Scopes -notcontains $result.Scope)) { + # Return 403 if token scopes do not intersect with required auth scopes + if ($hasAuthScopes -and $hasTokenScope -and (-not ($options.Scopes | Where-Object { $_ -in $result.Scope }))) { + $message = 'Token scope is insufficient' return @{ - Message = 'Invalid Scope' - Challenge = (New-PodeAuthBearerChallenge -Scopes $options.Scopes -ErrorType insufficient_scope) + Message = $message + Challenge = (New-PodeAuthChallenge -Scopes $options.Scopes -ErrorType insufficient_scope -ErrorDescription $message ) Code = 403 } } - # return result + # Return the validated token 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 ', ') -} function Get-PodeAuthDigestType { + <# + .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'). + + .OUTPUTS + A hashtable containing the following keys based on the validation result: + - Message: Error or success message. + - Code: HTTP response code. + - 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 | + + #> return { param($options) - + $nonce = (New-PodeGuid -Secure -NoDashes) # get the auth header - send challenge if missing $header = (Get-PodeHeader -Name 'Authorization') if ($null -eq $header) { + $message = 'No Authorization header found' return @{ - Message = 'No Authorization header found' - Challenge = (New-PodeAuthDigestChallenge) + Message = $message + Challenge = (New-PodeAuthChallenge -ErrorDescription $message -Nonce $nonce -Algorithm ($options.algorithm -join ', ') -QualityOfProtection $options.QualityOfProtection) Code = 401 } } @@ -584,16 +886,19 @@ function Get-PodeAuthDigestType { # if auth header isn't digest send challenge $atoms = $header -isplit '\s+' if ($atoms.Length -lt 2) { + $message = 'Invalid Authorization header format' return @{ - Message = 'Invalid Authorization header' - Code = 400 + Message = $message + Challenge = (New-PodeAuthChallenge -ErrorDescription $message -Nonce $nonce -Algorithm ($options.algorithm -join ', ') -QualityOfProtection $options.QualityOfProtection) + Code = 401 # RFC 7616: Invalid credentials format should return 401 } } if ($atoms[0] -ine $options.HeaderTag) { + $message = "Authorization header is not $($options.HeaderTag)" return @{ - Message = "Authorization header is not $($options.HeaderTag)" - Challenge = (New-PodeAuthDigestChallenge) + Message = $message + Challenge = (New-PodeAuthChallenge -ErrorDescription $message -Nonce $nonce -Algorithm ($options.algorithm -join ', ') -QualityOfProtection $options.QualityOfProtection) Code = 401 } } @@ -601,26 +906,31 @@ function Get-PodeAuthDigestType { # 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' return @{ - Message = 'Invalid Authorization header' - Code = 400 + Message = $message + Code = 400 + Challenge = (New-PodeAuthChallenge -ErrorDescription $message -Nonce $nonce -Algorithm ($options.algorithm -join ', ') -QualityOfProtection $options.QualityOfProtection) } } # if no username then 401 and challenge if ([string]::IsNullOrWhiteSpace($params.username)) { + $message = 'Authorization header is missing username' return @{ - Message = 'Authorization header is missing username' - Challenge = (New-PodeAuthDigestChallenge) + Message = $message + Challenge = (New-PodeAuthChallenge -ErrorDescription $message -Nonce $nonce -Algorithm ($options.algorithm -join ', ') -QualityOfProtection $options.QualityOfProtection) Code = 401 } } # return 400 if domain doesnt match request domain if ($WebEvent.Path -ine $params.uri) { + $message = 'Invalid Authorization header' return @{ - Message = 'Invalid Authorization header' - Code = 400 + Message = $message + Challenge = (New-PodeAuthChallenge -ErrorDescription $message -Nonce $nonce -Algorithm ($options.algorithm -join ', ') -QualityOfProtection $options.QualityOfProtection ) + Code = 400 } } @@ -630,42 +940,187 @@ function Get-PodeAuthDigestType { } function Get-PodeAuthDigestPostValidator { + <# +.SYNOPSIS + Validates HTTP Digest authentication responses for incoming requests. + +.DESCRIPTION + The `Get-PodeAuthDigestPostValidator` function processes and validates HTTP Digest + authentication responses by computing and verifying the response hash against + the client's provided hash. It ensures authentication is performed securely by + supporting multiple hashing algorithms and optional integrity protection (`auth-int`). + +.PARAMETER username + The username extracted from the client's authentication request. + +.PARAMETER params + A hashtable containing Digest authentication parameters, including: + - `username` : The username provided in the request. + - `realm` : The authentication realm. + - `nonce` : A unique server-generated nonce value. + - `uri` : The requested resource URI. + - `nc` : Nonce count (tracking the number of requests). + - `cnonce` : Client-generated nonce value. + - `qop` : Quality of Protection (`auth` or `auth-int`). + - `response` : The client's computed response hash. + - `algorithm` : The hashing algorithm used by the client. + +.PARAMETER result + A hashtable containing the user data retrieved from the authentication source. + This should include: + - `User` : The username. + - `Password` : The stored password or hash for verification. + +.PARAMETER options + A hashtable defining authentication options, including: + - `algorithm` : The list of supported hashing algorithms (MD5, SHA-256, etc.). + - `QualityOfProtection` : The supported Quality of Protection values (`auth`, `auth-int`). + +.OUTPUTS + - Returns the user data (with the password removed) on successful authentication. + - Returns an error response with a Digest authentication challenge and HTTP status code + if authentication fails. + +.NOTES + This scriptblock ensures robust Digest authentication by: + - Supporting multiple hashing algorithms (MD5, SHA-1, SHA-256, SHA-512/256, etc.). + - Handling authentication with and without message integrity (`auth` vs `auth-int`). + - Verifying authentication by comparing the computed hash with the client's response. + + **Behavior:** + - If the user is unknown or the password is missing, authentication fails with a `401 Unauthorized`. + - If the client selects an unsupported algorithm, authentication fails with `400 Bad Request`. + - If the computed response does not match the client’s hash, authentication fails with `401 Unauthorized`. + + **Digest Authentication Elements:** + - `qop="auth"`: Standard authentication (default). + - `qop="auth-int"`: Authentication with message integrity (includes request body hashing). + - `algorithm="MD5, SHA-256, SHA-512/256"`: Server-supported algorithms. + - `nonce=""`: Unique server nonce for replay protection. + +#> return { param($username, $params, $result, $options) - # if there's no user or password, fail with challenge + # If no user data or password is found, authentication fails if (($null -eq $result) -or ($null -eq $result.User) -or [string]::IsNullOrWhiteSpace($result.Password)) { + $message = 'Invalid credentials' return @{ - Message = 'User not found' - Challenge = (New-PodeAuthDigestChallenge) + Message = $message + Challenge = (New-PodeAuthChallenge -ErrorType invalid_request -Nonce $params.nonce ` + -Algorithm ($options.algorithm -join ', ') -QualityOfProtection $options.QualityOfProtection ` + -ErrorDescription $message) Code = 401 } } - # generate the first hash - $hash1 = Invoke-PodeMD5Hash -Value "$($params.username):$($params.realm):$($result.Password)" + # Extract the client-provided algorithm + $algorithm = $params.algorithm + + # Ensure the selected algorithm is supported by the server + if (-not ($options.algorithm -contains $algorithm)) { + $message = "Unsupported algorithm: $algorithm" + return @{ + Message = $message + Challenge = (New-PodeAuthChallenge -ErrorType invalid_request -Nonce $params.nonce ` + -Algorithm ($options.algorithm -join ', ') -QualityOfProtection $options.QualityOfProtection ` + -ErrorDescription $message) + Code = 400 + } + } + + # Extract Quality of Protection (qop) value + $qop = $params.qop + + # Retrieve the HTTP method (GET, POST, etc.) and the request URI + $method = $WebEvent.Method.ToUpperInvariant() + $uri = $params.uri + + # Compute HA1: Hash of (username:realm:password) + $HA1 = ConvertTo-PodeDigestHash -Value "$($params.username):$($params.realm):$($result.Password)" -Algorithm $algorithm - # generate the second hash - $hash2 = Invoke-PodeMD5Hash -Value "$($WebEvent.Method.ToUpperInvariant()):$($params.uri)" + # Compute HA2: Hash of request method and URI + if ($qop -eq 'auth-int') { + # If the request body is null, use an empty string (RFC 7616 compliance) + $entityBody = if ($null -eq $WebEvent.RawData) { [string]::Empty } else { $WebEvent.RawData } - # generate final hash - $final = Invoke-PodeMD5Hash -Value "$($hash1):$($params.nonce):$($params.nc):$($params.cnonce):$($params.qop):$($hash2)" + # Compute H(entity-body): Hash of request body (to ensure message integrity) + $entityHash = ConvertTo-PodeDigestHash -Value $entityBody -Algorithm $algorithm - # compare final hash to client response + # Compute HA2 for `auth-int`: Hash of (method:uri:H(entity-body)) + $HA2 = ConvertTo-PodeDigestHash -Value "$($method):$($uri):$($entityHash)" -Algorithm $algorithm + } + else { + # Standard HA2 computation for `auth`: Hash of (method:uri) + $HA2 = ConvertTo-PodeDigestHash -Value "$($method):$($uri)" -Algorithm $algorithm + } + + # Compute the final digest response hash + $final = ConvertTo-PodeDigestHash -Value "$($HA1):$($params.nonce):$($params.nc):$($params.cnonce):$($qop):$($HA2)" -Algorithm $algorithm + + # Compare the computed hash with the client's provided response if ($final -ne $params.response) { + $message = 'Invalid authentication response' return @{ - Message = 'Hashes failed to match' - Challenge = (New-PodeAuthDigestChallenge) + Message = $message + Challenge = (New-PodeAuthChallenge -ErrorType invalid_request -Nonce $params.nonce ` + -Algorithm ($options.algorithm -join ', ') -QualityOfProtection $options.QualityOfProtection ` + -ErrorDescription $message) Code = 401 } } - # hashes are valid, remove password and return result + # If hashes match, authentication is successful + # Remove the stored password from the result before returning the authenticated user $null = $result.Remove('Password') return $result } } +<# +.SYNOPSIS + Parses a Digest Authentication header and extracts its key-value pairs. + +.DESCRIPTION + The `ConvertFrom-PodeAuthDigestHeader` function takes an array of Digest authentication + header parts and converts them into a hashtable. This is used to process the + `WWW-Authenticate` and `Authorization` headers in Digest authentication requests. + +.PARAMETER Parts + An array of strings representing parts of the Digest authentication header. + These parts are typically extracted from the `WWW-Authenticate` or `Authorization` headers. + +.OUTPUTS + A hashtable containing the parsed key-value pairs from the Digest authentication header. + +.EXAMPLE + $header = @('Digest username="morty", realm="PodeRealm", nonce="abc123", uri="/users", response="xyz456"') + ConvertFrom-PodeAuthDigestHeader -Parts $header + + Returns: + @{ + username = "morty" + realm = "PodeRealm" + nonce = "abc123" + uri = "/users" + response = "xyz456" + } + +.EXAMPLE + # Handling empty or missing headers + ConvertFrom-PodeAuthDigestHeader -Parts @() + + Returns: + @{ } + +.NOTES + - This function ensures proper parsing of Digest authentication headers by correctly + handling quoted values and splitting by commas only when appropriate. + - The regex pattern ensures that quoted values (e.g., `nonce="abc123"`) are correctly extracted. + - If the input is empty or null, an empty hashtable is returned. + +#> + function ConvertFrom-PodeAuthDigestHeader { param( [Parameter()] @@ -673,28 +1128,83 @@ function ConvertFrom-PodeAuthDigestHeader { $Parts ) + # Return an empty hashtable if no header parts are provided if (($null -eq $Parts) -or ($Parts.Length -eq 0)) { return @{} } + # Initialize a hashtable to store parsed key-value pairs $obj = @{} + + # Join all parts into a single string to process as one header $value = ($Parts -join ' ') + # Split by commas, ensuring quoted values remain intact @($value -isplit ',(?=(?:[^"]|"[^"]*")*$)') | ForEach-Object { + # Match key-value pairs (handles both quoted and unquoted values) if ($_ -imatch '(?\w+)=["]?(?[^"]+)["]?$') { $obj[$Matches['name']] = $Matches['value'] } } + # Return the parsed hashtable return $obj } -function New-PodeAuthDigestChallenge { - $items = @('qop="auth"', 'algorithm="MD5"', "nonce=`"$(New-PodeGuid -Secure -NoDashes)`"") - return ($items -join ', ') -} function Get-PodeAuthFormType { + <# +.SYNOPSIS + Processes form-based authentication requests. + +.DESCRIPTION + The `Get-PodeAuthFormType` function extracts and validates user credentials from + an incoming HTTP form submission. It verifies the presence and format of the + provided username and password and optionally converts them to secure credentials. + +.PARAMETER $options + A hashtable containing configuration options for the authentication process. + Expected keys: + - `Fields.Username`: The key used to extract the username from the request data. + - `Fields.Password`: The key used to extract the password from the request data. + - `AsCredential`: (Boolean) If true, converts credentials into a [PSCredential] object. + +.OUTPUTS + [array] + Returns an array containing the validated username and password. + If `AsCredential` is set to `$true`, returns a `[PSCredential]` object. + +.EXAMPLE + $options = @{ + Fields = @{ Username = 'user'; Password = 'pass' } + AsCredential = $false + } + $result = Get-PodeAuthFormType -options $options + + Returns: + @('user123', 'securePassword') + +.EXAMPLE + $options = @{ + Fields = @{ Username = 'user'; Password = 'pass' } + AsCredential = $true + } + $result = Get-PodeAuthFormType -options $options + + Returns: + [PSCredential] object containing username and password. + +.NOTES + This function performs several checks, including: + - Ensuring both username and password are provided. + - Validating the username format (only alphanumeric, dot, underscore, and dash allowed). + - Returning HTTP status codes and error messages in case of validation failures. + + Possible HTTP response codes: + - 401 Unauthorized: When credentials are missing or incomplete. + - 400 Bad Request: When the username format is invalid. + +#> return { param($options) @@ -706,11 +1216,41 @@ function Get-PodeAuthFormType { $username = $WebEvent.Data.$userField $password = $WebEvent.Data.$passField - # if either are empty, fail auth - if ([string]::IsNullOrWhiteSpace($username) -or [string]::IsNullOrWhiteSpace($password)) { + # Handle cases where fields are missing or empty + if ([string]::IsNullOrWhiteSpace($username) -and [string]::IsNullOrWhiteSpace($password)) { + $message = 'Username and password must be provided' return @{ - Message = 'Username or Password not supplied' - Code = 401 + Message = $message + Challenge = (New-PodeAuthChallenge -ErrorType invalid_request -ErrorDescription $message) + Code = 401 + } + } + + if ([string]::IsNullOrWhiteSpace($username)) { + $message = 'Username is required' + return @{ + Message = $message + Challenge = (New-PodeAuthChallenge -ErrorType invalid_request -ErrorDescription $message) + Code = 401 + } + } + + if ([string]::IsNullOrWhiteSpace($password)) { + $message = 'Password is required' + return @{ + Message = $message + Challenge = (New-PodeAuthChallenge -ErrorType invalid_request -ErrorDescription $message) + Code = 401 + } + } + + # Validate username format + if ($username -notmatch '^[a-zA-Z0-9._-]{3,20}$') { + $message = 'Invalid username format' + return @{ + Message = $message + Challenge = (New-PodeAuthChallenge -ErrorType invalid_request -ErrorDescription $message) + Code = 400 } } @@ -729,7 +1269,9 @@ function Get-PodeAuthFormType { } } -<# + +function Get-PodeAuthUserFileMethod { + <# .SYNOPSIS Authenticates a user based on a username and password provided as parameters. @@ -756,7 +1298,6 @@ function Get-PodeAuthFormType { 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) @@ -826,71 +1367,11 @@ function Get-PodeAuthUserFileMethod { } } -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, +function Invoke-PodeAuthInbuiltScriptBlock { + param( + [Parameter(Mandatory = $true)] + [hashtable] + $User, [Parameter(Mandatory = $true)] [scriptblock] @@ -972,138 +1453,6 @@ function Get-PodeAuthWindowsLocalMethod { } } -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. @@ -1238,6 +1587,24 @@ function Invoke-PodeAuthValidation { 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. + +.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)] @@ -1246,13 +1613,13 @@ function Test-PodeAuthValidation { ) try { - # get auth method + # Retrieve authentication method configuration from Pode context $auth = $PodeContext.Server.Authentications.Methods[$Name] - # auth result + # Initialize authentication result variable $result = $null - # run pre-auth middleware + # Run pre-authentication middleware if defined if ($null -ne $auth.Scheme.Middleware) { if (!(Invoke-PodeMiddleware -Middleware $auth.Scheme.Middleware)) { return @{ @@ -1261,26 +1628,28 @@ function Test-PodeAuthValidation { } } - # run auth scheme script to parse request for data + # Prepare arguments for the authentication scheme script $_args = @(Merge-PodeScriptblockArguments -ArgumentList $auth.Scheme.Arguments -UsingVariables $auth.Scheme.ScriptBlock.UsingVariables) - # call inner schemes first + # 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 + break # Exit if a valid result is returned } $schemes += , $result @@ -1290,25 +1659,27 @@ function Test-PodeAuthValidation { $_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) } - # if data is a hashtable, then don't call validator (parser either failed, or forced a success) + # 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) - # if we have user, then run post validator if present + # 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) } } - # is the auth trying to redirect ie: oauth? + # Handle authentication redirection scenarios (e.g., OAuth) if ($result.IsRedirected) { return @{ Success = $false @@ -1316,11 +1687,12 @@ function Test-PodeAuthValidation { } } - # if there's no result, or no user, then the auth failed - but allow auth if anon enabled + + # 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 the www-auth header + # Set WWW-Authenticate header for appropriate HTTP response $validCode = (($code -eq 401) -or ![string]::IsNullOrEmpty($result.Challenge)) if ($validCode) { @@ -1332,6 +1704,7 @@ function Test-PodeAuthValidation { $result.Headers = @{} } + # Generate authentication challenge header if (![string]::IsNullOrWhiteSpace($auth.Scheme.Name) -and !$result.Headers.ContainsKey('WWW-Authenticate')) { $authHeader = Get-PodeAuthWwwHeaderValue -Name $auth.Scheme.Name -Realm $auth.Scheme.Realm -Challenge $result.Challenge $result.Headers['WWW-Authenticate'] = $authHeader @@ -1347,7 +1720,7 @@ function Test-PodeAuthValidation { } } - # authentication was successful + # Authentication succeeded, return user and headers return @{ Success = $true User = $result.User @@ -1356,6 +1729,8 @@ function Test-PodeAuthValidation { } catch { $_ | Write-PodeErrorLog + + # Handle unexpected errors and log them return @{ Success = $false StatusCode = 500 @@ -1364,6 +1739,8 @@ function Test-PodeAuthValidation { } } + + function Get-PodeAuthMiddlewareScript { return { param($opts) @@ -1730,655 +2107,331 @@ function Set-PodeAuthStatus { 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 { +function Find-PodeAuth { param( - [Parameter()] - [string] - $Server, - - [Parameter()] - [string] - $Domain, - - [Parameter()] - [string] - $SearchBase, - - [Parameter()] - [string] - $Username, - - [Parameter()] - [string] - $Password, - - [Parameter()] - [ValidateSet('DirectoryServices', 'ActiveDirectory', 'OpenLDAP')] + [Parameter(Mandatory = $true)] + [ValidateNotNullOrEmpty()] [string] - $Provider, - - [switch] - $NoGroups, - - [switch] - $DirectGroups, - - [switch] - $KeepCredential + $Name ) - 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 - } - } - } - } + return $PodeContext.Server.Authentications.Methods[$Name] } -function Open-PodeAuthADConnection { - param( - [Parameter(Mandatory = $true)] - [string] - $Server, - - [Parameter()] - [string] - $Domain, - - [Parameter()] - [string] - $SearchBase, +<# +.SYNOPSIS + Expands a list of authentication names, including merged authentication methods. - [Parameter()] - [string] - $Username, +.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()] - [string] - $Password, +.PARAMETER Names + An array of authentication method names. These names can include both discrete authentication methods and merged ones. - [Parameter()] - [ValidateSet('LDAP', 'WinNT')] - [string] - $Protocol = 'LDAP', +.EXAMPLE + $expandedAuthNames = Expand-PodeAuthMerge -Names @('BasicAuth', 'CustomMergedAuth') - [Parameter()] - [ValidateSet('DirectoryServices', 'ActiveDirectory', 'OpenLDAP')] - [string] - $Provider + 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 ) - $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)" - } + # Initialize a hashtable to store expanded authentication names + $authNames = @{} - $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 - } - } + # 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] - 'activedirectory' { - try { - $creds = [pscredential]::new($Username, (ConvertTo-SecureString -String $Password -AsPlainText -Force)) - $null = Get-ADUser -Identity $Username -Credential $creds -ErrorAction Stop - $connection = @{ - Credential = $creds + # 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 } } - 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 - } + # If not merged, add the authentication name to the list + $authNames[$_auth.Name] = $true } } } - return @{ - Success = $result - Connection = $connection - } + # Return the keys of the hashtable, which are the expanded authentication names + return $authNames.Keys } -function Get-PodeAuthADQuery { - param( - [Parameter(Mandatory = $true)] - [string] - $Username - ) - return "(&(objectCategory=person)(samaccountname=$($Username)))" -} - -function Get-PodeAuthADUser { +function Set-PodeAuthRedirectUrl { param( - [Parameter(Mandatory = $true)] - $Connection, - - [Parameter(Mandatory = $true)] - [string] - $Username, - - [Parameter()] - [ValidateSet('DirectoryServices', 'ActiveDirectory', 'OpenLDAP')] - [string] - $Provider + [switch] + $UseOrigin ) - $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] - } - } + if ($UseOrigin -and ($WebEvent.Method -ieq 'get')) { + $null = Set-PodeCookie -Name 'pode.redirecturl' -Value $WebEvent.Request.Url.PathAndQuery } - - return $user } -function Get-PodeOpenLdapValue { +function Get-PodeAuthRedirectUrl { param( - [Parameter()] - [string[]] - $Lines, - [Parameter()] [string] - $Property, + $Url, [switch] - $All + $UseOrigin ) - foreach ($line in $Lines) { - if ($line -imatch "^$($Property)\:\s+(?<$($Property)>.+)$") { - # return the first found - if (!$All) { - return $Matches[$Property] - } + if (!$UseOrigin) { + return $Url + } - # return array of all - $Matches[$Property] - } + $tmpUrl = Get-PodeCookieValue -Name 'pode.redirecturl' + Remove-PodeCookie -Name 'pode.redirecturl' + + if (![string]::IsNullOrWhiteSpace($tmpUrl)) { + $Url = $tmpUrl } + + return $Url } + + <# .SYNOPSIS - Retrieves Active Directory (AD) group information for a user. + Generates the WWW-Authenticate challenge header for failed authentication attempts. .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. + The `New-PodeAuthChallenge` function constructs a formatted authentication challenge + string to be included in HTTP responses when authentication fails. + It supports optional parameters such as scopes, error types, descriptions, + and digest authentication mechanisms. + +.PARAMETER Scopes + An array of required scopes to be included in the challenge response. + Scopes define the level of access required for the requested resource. + +.PARAMETER ErrorType + Specifies the type of error to include in the challenge response. + Accepted values are: + - 'invalid_request' : The request is missing a required parameter. + - 'invalid_token' : The provided token is expired, revoked, or invalid. + - 'insufficient_scope' : The provided token lacks necessary privileges. + +.PARAMETER ErrorDescription + Provides a descriptive error message in the challenge response to explain + the reason for the authentication failure. + +.PARAMETER Digest + A switch parameter that, when specified, includes digest authentication elements + such as quality of protection (qop), algorithm, and a unique nonce value. -.PARAMETER DistinguishedName - The distinguished name (DN) of the user or group. If not provided, the default DN is used. +.OUTPUTS + [string] + Returns a formatted challenge string to be used in the HTTP response header. -.PARAMETER Username - The username for which to retrieve group information. +.EXAMPLE + New-PodeAuthChallenge -Scopes @('read', 'write') -ErrorType 'invalid_token' -ErrorDescription 'Token has expired' -.PARAMETER Provider - The AD provider to use (e.g., 'DirectoryServices', 'ActiveDirectory', 'OpenLDAP'). + Returns: + scope="read write", error="invalid_token", error_description="Token has expired" -.PARAMETER Direct - Switch parameter. If specified, retrieves only direct group memberships for the user. +.EXAMPLE + New-PodeAuthChallenge -Digest -.OUTPUTS - Returns AD group information as needed based on the mode of operation. + Returns: + qop="auth", algorithm="MD5", nonce="generated_nonce" .EXAMPLE - Get-PodeAuthADGroup -Connection $adConnection -Username "john.doe" - # Retrieves all AD groups for the user "john.doe". + New-PodeAuthChallenge -Scopes @('admin') -ErrorType 'insufficient_scope' + + Returns: + scope="admin", error="insufficient_scope" - Get-PodeAuthADGroup -Connection $adConnection -Username "jane.smith" -Direct - # Retrieves only direct group memberships for the user "jane.smith". +.NOTES + This function is used to generate the `WWW-Authenticate` response header + when authentication attempts fail. It helps inform clients of the authentication + requirements and reasons for failure. #> -function Get-PodeAuthADGroup { - param( - [Parameter(Mandatory = $true)] - $Connection, +function New-PodeAuthChallenge { + param( [Parameter()] - [string] - $DistinguishedName, + [string[]] + $Scopes, [Parameter()] + [ValidateSet('invalid_request', 'invalid_token', 'insufficient_scope')] [string] - $Username, + $ErrorType = 'invalid_request', [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, + $ErrorDescription, [Parameter()] [string] - $Username, + $Nonce, [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, + [string[]] + $Algorithm = 'md5', [Parameter()] - [string] - $DistinguishedName, + [string[]] + $QualityOfProtection = 'auth' - [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) - } + $items = @() - $null = $Connection.Searcher.PropertiesToLoad.Add('samaccountname') - $Connection.Searcher.filter = $query - $groups = @($Connection.Searcher.FindAll().Properties.samaccountname) - } + if (![string]::IsNullOrWhiteSpace($Nonce)) { + $items += "qop=`"$QualityOfProtection`"", "algorithm=$Algorithm" , "nonce=`"$Nonce`"" } - 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) - } + if (($null -ne $Scopes) -and ($Scopes.Length -gt 0)) { + $items += "scope=`"$($Scopes -join ' ')`"" } - else { - $domain = $env:USERDNSDOMAIN - if ([string]::IsNullOrWhiteSpace($domain)) { - $domain = (Get-CimInstance -Class Win32_ComputerSystem -Verbose:$false).Domain - } + + if (![string]::IsNullOrWhiteSpace($ErrorType)) { + $items += "error=`"$($ErrorType)`"" } - if (![string]::IsNullOrEmpty($domain)) { - $domain = $domain.Trim() + if (![string]::IsNullOrWhiteSpace($ErrorDescription)) { + $items += "error_description=`"$($ErrorDescription)`"" } - return $domain + return ($items -join ', ') } -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. + Validates that the HTTP method supports bearer token authentication in the body. .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. + This function checks if the provided HTTP method is one that typically supports request bodies (e.g., PUT, POST, PATCH) when bearer token authentication is expected in the body. Throws an error if the method is not supported. -.PARAMETER Names - An array of authentication method names. These names can include both discrete authentication methods and merged ones. +.PARAMETER Method + The HTTP method to validate (e.g., GET, POST, PUT, PATCH). + +.PARAMETER Authentication + The authentication scheme to validate against Pode's configured authentications. .EXAMPLE - $expandedAuthNames = Expand-PodeAuthMerge -Names @('BasicAuth', 'CustomMergedAuth') + Test-PodeBodyAuthMethod -Method 'POST' -Authentication 'Bearer' + # Validates successfully as POST supports body authentication. - Expands the provided authentication names, resolving 'CustomMergedAuth' into its constituent authentication methods if it's a merged one. +.EXAMPLE + Test-PodeBodyAuthMethod -Method 'GET' -Authentication 'Bearer' + # Throws an error as GET does not support body authentication. + +.NOTES + Internal Pode function for HTTP authentication validation. Subject to change. #> -function Expand-PodeAuthMerge { +function Test-PodeBodyAuthMethod { param ( [Parameter(Mandatory = $true)] - [ValidateNotNullOrEmpty()] [string[]] - $Names - ) + $Method, - # Initialize a hashtable to store expanded authentication names - $authNames = @{} + [Parameter(Mandatory = $true)] + [string] + $Authentication + ) - # Iterate over each authentication name - foreach ($authName in $Names) { - # Handle the special case of anonymous access - if ($authName -eq '%_allowanon_%') { - $authNames[$authName] = $true + if (![string]::IsNullOrWhiteSpace($Authentication) -and $PodeContext.Server.Authentications.Methods.ContainsKey($Authentication)) { + $uberAuth = $PodeContext.Server.Authentications.Methods[$Authentication] + if ($uberAuth.ContainsKey('Authentications')) { + $authentications = $uberAuth.Authentications } 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 + $authentications = @($Authentication) + } + foreach ($auth in $authentications) { + switch ($PodeContext.Server.Authentications.Methods[$auth].Scheme.Name) { + 'Digest' { + $arguments = $PodeContext.Server.Authentications.Methods[$auth].Scheme.Arguments + if (($null -ne $arguments ) -and ($arguments.QualityOfProtection -eq 'auth-int')) { + $Method | Foreach-Object({ if ($_ -inotmatch '^(PUT|POST|PATCH)$') { + throw $PodeLocale.digestTokenAuthMethodNotSupportedExceptionMessage + } }) + } + } + default { + $arguments = $PodeContext.Server.Authentications.Methods[$auth].Scheme.Arguments + if (($null -ne $arguments ) -and $arguments.ContainsKey('Location') -and $arguments['Location'] -eq 'body') { + $Method | Foreach-Object({ if ($_ -inotmatch '^(PUT|POST|PATCH)$') { + throw $PodeLocale.bearerTokenAuthMethodNotSupportedExceptionMessage + } }) + } } - } - 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 - ) +<# +.SYNOPSIS + Retrieves the Bearer token from an HTTP request based on authentication configuration. - if ($UseOrigin -and ($WebEvent.Method -ieq 'get')) { - $null = Set-PodeCookie -Name 'pode.redirecturl' -Value $WebEvent.Request.Url.PathAndQuery - } -} +.DESCRIPTION + The `Get-PodeBearerToken` function extracts the Bearer token from an HTTP request, depending on + the authentication method's configured token location. It supports retrieval from the request's + header, query parameters, or body. -function Get-PodeAuthRedirectUrl { - param( - [Parameter()] - [string] - $Url, +.PARAMETER Authentication + Specifies the authentication method configured in Pode. The function checks if the method exists + within the server's authentication methods. - [switch] - $UseOrigin - ) +.OUTPUTS + [string] + Returns the extracted Bearer token as a string. If the authentication method does not exist, + the function throws an exception. - if (!$UseOrigin) { - return $Url +.EXAMPLE + $token = Get-PodeBearerToken + # Retrieves the Bearer token from the request's headers, query parameters, or body. + +.NOTES + - This function depends on Pode's authentication context and must be used within a Pode route. + - The token location is determined based on the authentication method's configuration. + - If the authentication method does not exist, an exception is thrown. + - Supported token locations: `header`, `query`, `body`. +#> +function Get-PodeBearenToken { + if ($PodeContext -and $PodeContext.Server.Authentications.Methods.ContainsKey($Authentication)) { + $authOptions = $PodeContext.Server.Authentications.Methods[$Authentication].Scheme.Arguments + switch ($authOptions.Location.ToLowerInvariant()) { + 'header' { + $atoms = $(Get-PodeHeader -Name 'Authorization') -isplit '\s+' + return $atoms[1] + } + 'query' { + return $WebEvent.Query[$options.BearerTag] + } + 'body' { + return $WebEvent.Data.($options.BearerTag) + } + } } - - $tmpUrl = Get-PodeCookieValue -Name 'pode.redirecturl' - Remove-PodeCookie -Name 'pode.redirecturl' - - if (![string]::IsNullOrWhiteSpace($tmpUrl)) { - $Url = $tmpUrl + else { + throw ($PodeLocale.authenticationMethodDoesNotExistExceptionMessage) } - - return $Url } \ No newline at end of file diff --git a/src/Private/Certificate.ps1 b/src/Private/Certificate.ps1 new file mode 100644 index 000000000..ad37ab09b --- /dev/null +++ b/src/Private/Certificate.ps1 @@ -0,0 +1,425 @@ +<# +.SYNOPSIS + Exports a private key in PEM format, optionally encrypting it with a password. + +.DESCRIPTION + This function exports a private key in PEM format using PKCS#8 encoding. + If a password is provided, the private key is encrypted using AES-256-CBC + with SHA-256 hashing and 100,000 iterations. The function supports both + PowerShell 7+ and older versions, using native methods where available. + +.PARAMETER Key + The asymmetric key to export. Must be an instance of System.Security.Cryptography.AsymmetricAlgorithm. + +.PARAMETER Password + A secure string containing the password for encrypting the private key. + If omitted, the private key is exported unencrypted. + +.OUTPUTS + [string] + Returns the private key as a PEM-formatted string. + +.EXAMPLE + $rsa = [System.Security.Cryptography.RSA]::Create(2048) + $pem = Export-PodePrivateKeyPem -Key $rsa + Exports an unencrypted private key in PEM format. + +.EXAMPLE + $rsa = [System.Security.Cryptography.RSA]::Create(2048) + $securePassword = ConvertTo-SecureString -String "MyStrongPass" -AsPlainText -Force + $pem = Export-PodePrivateKeyPem -Key $rsa -Password $securePassword + Exports an encrypted private key in PEM format using the provided password. + +.NOTES + This function ensures compatibility with both PowerShell 7+ (using native PEM methods) + and older versions (manually constructing the PEM format). It is designed for use + within Pode’s cryptographic handling utilities. + This is an internal Pode function and may be subject to change. +#> +function Export-PodePrivateKeyPem { + param ( + [Parameter(Mandatory = $true)] + [System.Security.Cryptography.AsymmetricAlgorithm] + $Key, + + [Parameter()] + [securestring] + $Password + ) + $builder = [System.Text.StringBuilder]::new() + + if ($null -ne $Password) { + if ($PSVersionTable.PSVersion.Major -ge 7) { + # Export encrypted private key in PEM using the native method + return $Key.ExportEncryptedPkcs8PrivateKeyPem( + (Convert-PodeSecureStringToPlainText($Password)), + [System.Security.Cryptography.PbeParameters]::new( + [System.Security.Cryptography.PbeEncryptionAlgorithm]::Aes256Cbc, + [System.Security.Cryptography.HashAlgorithmName]::SHA256, + 100000 + ) + ) + } + # For older versions, export encrypted key using PKCS#8 format + $encryptedBytes = $Key.ExportEncryptedPkcs8PrivateKey( + (Convert-PodeSecureStringToPlainText($Password)), + [System.Security.Cryptography.PbeParameters]::new( + [System.Security.Cryptography.PbeEncryptionAlgorithm]::Aes256Cbc, + [System.Security.Cryptography.HashAlgorithmName]::SHA256, + 100000 + ) + ) + $base64Key = [Convert]::ToBase64String($encryptedBytes) + $null = $builder.AppendLine('-----BEGIN ENCRYPTED PRIVATE KEY-----') + } + else { + if ($PSVersionTable.PSVersion.Major -ge 7) { + # Export unencrypted private key in PEM using the native method + return $Key.ExportPkcs8PrivateKeyPem() + } + # For older versions, export unencrypted key using PKCS#8 format + $unencryptedBytes = $Key.ExportPkcs8PrivateKey() + $base64Key = [Convert]::ToBase64String($unencryptedBytes) + $null = $builder.AppendLine('-----BEGIN PRIVATE KEY-----') + } + + for ($i = 0; $i -lt $base64Key.Length; $i += 64) { + $null = $builder.AppendLine($base64Key.Substring($i, [System.Math]::Min(64, $base64Key.Length - $i))) + } + $null = $builder.AppendLine('-----END PRIVATE KEY-----') + return $builder.ToString() +} + +<# +.SYNOPSIS + Generates a certificate signing request (CSR) with specified parameters. + +.DESCRIPTION + This function creates a certificate signing request (CSR) using RSA or ECDSA key pairs. + It supports specifying subject details, key usage, enhanced key usage (EKU), + and custom extensions. The function returns a PSCustomObject containing + the CSR in Base64 format, the request object, the generated private key, + and additional metadata. + +.PARAMETER DnsName + One or more DNS names (or IP addresses) to be included in the Subject Alternative Name (SAN). + +.PARAMETER CommonName + The Common Name (CN) for the certificate subject. Defaults to the first DNS name if not provided. + +.PARAMETER Organization + The organization (O) name to be included in the certificate subject. + +.PARAMETER Locality + The locality (L) name to be included in the certificate subject. + +.PARAMETER State + The state (S) name to be included in the certificate subject. + +.PARAMETER Country + The country (C) code (ISO 3166-1 alpha-2). Defaults to 'XX'. + +.PARAMETER KeyType + The cryptographic key type for the certificate request. Supported values: 'RSA', 'ECDSA'. Defaults to 'RSA'. + +.PARAMETER KeyLength + The key length for RSA (2048, 3072, 4096) or ECDSA (256, 384, 521). Defaults to 2048. + +.PARAMETER CertificatePurpose + The intended purpose of the certificate, which automatically sets the EKU. + Supported values: 'ServerAuth', 'ClientAuth', 'CodeSigning', 'EmailSecurity', 'Custom'. + +.PARAMETER EnhancedKeyUsages + A list of OID strings for Enhanced Key Usage (EKU) if 'Custom' is selected as CertificatePurpose. + +.PARAMETER CustomExtensions + An array of additional custom certificate extensions. + +.OUTPUTS + [PSCustomObject] (PsTypeName = 'PodeCertificateRequest') + Returns an object containing: + - Request: The CSR in Base64 format. + - CertificateRequest: The generated certificate request object. + - PrivateKey: The generated private key. + +.EXAMPLE + $csr = New-PodeCertificateRequestInternal -DnsName "example.com" -CommonName "example.com" -KeyType "RSA" -KeyLength 2048 + Creates a certificate request for "example.com" using RSA with a 2048-bit key. + +.EXAMPLE + $csr = New-PodeCertificateRequestInternal -DnsName "example.com" -KeyType "ECDSA" -KeyLength 384 -CertificatePurpose "ServerAuth" + Generates an ECDSA certificate request for "example.com" with an automatically assigned EKU for server authentication. + +.NOTES + This is an internal Pode function and may be subject to change. + It is designed to integrate with Pode’s SSL and security handling mechanisms. +#> +function New-PodeCertificateRequestInternal { + [CmdletBinding(DefaultParameterSetName = 'CommonName')] + [OutputType([PSCustomObject])] + param ( + # Required: one or more DNS names (or IP addresses) + [Parameter()] + [string[]] + $DnsName, + + # Subject parts + [Parameter()] + [string] + $CommonName, + + [Parameter()] + [string] + $Organization, + + [Parameter()] + [string] + $Locality, + + [Parameter()] + [string] + $State, + + [Parameter()] + [string] + $Country = 'XX', + + # Key type and size + [Parameter()] + [ValidateSet('RSA', 'ECDSA')] + [string]$KeyType = 'RSA', + + [Parameter()] + [ValidateSet(2048, 3072, 4096, 256, 384, 521)] + [int]$KeyLength = 2048, + + #Automatically set EKUs based on intended purpose + [Parameter()] + [ValidateSet('ServerAuth', 'ClientAuth', 'CodeSigning', 'EmailSecurity', 'Custom')] + [string] + $CertificatePurpose, + + # Enhanced Key Usages (EKU) - supply one or more OID strings if desired. + [Parameter()] + [string[]] + $EnhancedKeyUsages, + + # Additional custom extensions (as an array of certificate extension objects). + [Parameter()] + [object[]] + $CustomExtensions + ) + + # Assign EKU based on selected purpose + $ekuOids = switch ($CertificatePurpose) { + 'ServerAuth' { @('1.3.6.1.5.5.7.3.1') } # Server Authentication (HTTPS/TLS) + 'ClientAuth' { @('1.3.6.1.5.5.7.3.2') } # Client Authentication (VPN, Mutual TLS) + 'CodeSigning' { @('1.3.6.1.5.5.7.3.3') } # Code Signing (JWT, Software) + 'EmailSecurity' { @('1.3.6.1.5.5.7.3.4') } # Email Security (S/MIME) + 'Custom' { $EnhancedKeyUsages } # Use manually supplied OIDs + default { $null } + } + + # Ensure CommonName is set (fallback to first DNS entry if missing) + if (-not $CommonName -and $DnsName.Count -gt 0) { + $CommonName = $DnsName[0] + } + if (-not $CommonName) { + $CommonName = 'SelfSigned' + } + + + # Build the Distinguished Name (DN) string. + $subjectParts = @("CN=$CommonName") + if (![string]::IsNullOrEmpty($Organization)) { $subjectParts += "O=$Organization" } + if (![string]::IsNullOrEmpty($Locality)) { $subjectParts += "L=$Locality" } + if (![string]::IsNullOrEmpty( $State)) { $subjectParts += "S=$State" } + $subjectParts += "C=$Country" + $SubjectDN = $subjectParts -join ', ' + + # Initialize the SAN (Subject Alternative Name) builder. + $sanBuilder = $null + if ($DnsName) { + $sanBuilder = [System.Security.Cryptography.X509Certificates.SubjectAlternativeNameBuilder]::new() + foreach ($name in $DnsName) { + $parsedIp = $null + if ([System.Net.IPAddress]::TryParse($name, [ref]$parsedIp)) { + $sanBuilder.AddIpAddress($parsedIp) + } + else { + $sanBuilder.AddDnsName($name) + } + } + } + + # Generate key pair and certificate request based on the chosen key type. + switch ($KeyType) { + 'RSA' { + if (@(2048, 3072, 4096) -notcontains $KeyLength ) { + ($PodeLocale.unsupportedCertificateKeyLengthExceptionMessage -f $KeyLength) + } + $key = [System.Security.Cryptography.RSA]::Create($KeyLength) + if (! $key) { throw $PodeLocale.failedToCreateCertificateRequestExceptionMessage } + $distinguishedName = [X500DistinguishedName]::new($SubjectDN) + $hashAlgorithm = [System.Security.Cryptography.HashAlgorithmName]::SHA256 + $rsaPadding = [System.Security.Cryptography.RSASignaturePadding]::Pkcs1 + $req = [System.Security.Cryptography.X509Certificates.CertificateRequest]::new( + $distinguishedName, + $key, + $hashAlgorithm, + $rsaPadding + ) + } + 'ECDSA' { + $curveOid = switch ($KeyLength) { + 256 { '1.2.840.10045.3.1.7' } # nistP256 + 384 { '1.3.132.0.34' } # nistP384 + 521 { '1.3.132.0.35' } # nistP521 + default { throw ($PodeLocale.unsupportedCertificateKeyLengthExceptionMessage -f $KeyLength) } + } + $curve = [System.Security.Cryptography.ECCurve]::CreateFromOid( + [System.Security.Cryptography.Oid]::new($curveOid) + ) + $key = [System.Security.Cryptography.ECDsa]::Create($curve) + if (-not $key) { throw $PodeLocale.failedToCreateCertificateRequestExceptionMessage } + $hashAlgorithm = switch ($KeyLength) { + 256 { [System.Security.Cryptography.HashAlgorithmName]::SHA256 } + 384 { [System.Security.Cryptography.HashAlgorithmName]::SHA384 } + 521 { [System.Security.Cryptography.HashAlgorithmName]::SHA512 } + } + $req = [System.Security.Cryptography.X509Certificates.CertificateRequest]::new( + $SubjectDN, + $key, + $hashAlgorithm + ) + } + } + + if (! $req) { throw $PodeLocale.failedToCreateCertificateRequestExceptionMessage } + + # Add Basic Constraints (as a CA: false certificate). + $req.CertificateExtensions.Add( + [System.Security.Cryptography.X509Certificates.X509BasicConstraintsExtension]::new($false, $false, 0, $true) + ) + + # Add Key Usage extension. + $keyUsageFlags = ( + [System.Security.Cryptography.X509Certificates.X509KeyUsageFlags]::DigitalSignature -bor + [System.Security.Cryptography.X509Certificates.X509KeyUsageFlags]::KeyEncipherment -bor + [System.Security.Cryptography.X509Certificates.X509KeyUsageFlags]::DataEncipherment + ) + $req.CertificateExtensions.Add( + [System.Security.Cryptography.X509Certificates.X509KeyUsageExtension]::new($keyUsageFlags, $false) + ) + + # Add Subject Alternative Name (SAN) extension. + if ($sanBuilder) { + $req.CertificateExtensions.Add($sanBuilder.Build()) + } + + # Add EKU extension + if ($ekuOids) { + $oidCollection = [System.Security.Cryptography.OidCollection]::new() + foreach ($oid in $ekuOids) { + $oidCollection.Add([System.Security.Cryptography.Oid]::new($oid)) | Out-Null + } + $ekuExtension = [System.Security.Cryptography.X509Certificates.X509EnhancedKeyUsageExtension]::new($oidCollection, $false) + $req.CertificateExtensions.Add($ekuExtension) + } + + # Add any additional custom extensions. + if ($CustomExtensions) { + foreach ($ext in $CustomExtensions) { + $req.CertificateExtensions.Add($ext) + } + } + + # Create the signing request (CSR) in PKCS#10 format. + $csrBytes = $req.CreateSigningRequest() + $csrBase64 = [System.Convert]::ToBase64String($csrBytes) + + return [PSCustomObject]@{ + PsTypeName = 'PodeCertificateRequest' + Request = $csrBase64 + CertificateRequest = $req + PrivateKey = $key + } +} + + +<# +.SYNOPSIS + Validates whether an X.509 certificate is authorized for a specific purpose. + +.DESCRIPTION + This internal function checks if an X.509 certificate contains the necessary Enhanced Key Usage (EKU) + for an expected purpose. If the certificate lacks the required EKU, an exception is thrown. + + If `-Strict` mode is enabled, the function also rejects certificates containing unknown EKUs. + +.PARAMETER Certificate + The X509Certificate2 object to validate. + +.PARAMETER ExpectedPurpose + The required purpose for the certificate. Supported values: + - 'ServerAuth' (1.3.6.1.5.5.7.3.1) + - 'ClientAuth' (1.3.6.1.5.5.7.3.2) + - 'CodeSigning' (1.3.6.1.5.5.7.3.3) + - 'EmailSecurity' (1.3.6.1.5.5.7.3.4) + +.PARAMETER Strict + If specified, the function will **fail** if the certificate contains unknown EKUs. + +.OUTPUTS + [boolean] + Returns `$true` if the certificate is valid for the specified purpose. + Throws an exception if the certificate lacks the required EKU or contains unknown EKUs in `-Strict` mode. + +.EXAMPLE + Test-PodeCertificateRestriction -Certificate $cert -ExpectedPurpose 'ServerAuth' + Validates whether the given certificate can be used for server authentication. + +.EXAMPLE + Test-PodeCertificateRestriction -Certificate $cert -ExpectedPurpose 'ClientAuth' -Strict + Validates whether the certificate is authorized for client authentication, rejecting unknown EKUs. + +.NOTES + This is an internal Pode function and may be subject to change. +#> +function Test-PodeCertificateRestriction { + [CmdletBinding()] + param ( + [Parameter(Mandatory = $true)] + [System.Security.Cryptography.X509Certificates.X509Certificate2]$Certificate, + + [Parameter(Mandatory = $true)] + [ValidateSet('ServerAuth', 'ClientAuth', 'CodeSigning', 'EmailSecurity')] + [string]$ExpectedPurpose, + + [Parameter()] + [switch]$Strict + ) + + # Get the actual purposes of the certificate + $purposes = Get-PodeCertificatePurpose -Certificate $Certificate + + # If the certificate has no EKU and no restrictions, allow it (but warn) + if ($purposes.Count -eq 0 -and ! $Strict) { + Write-Verbose 'Certificate has no EKU restrictions. It can be used for any purpose.' + return + } + + # If the expected purpose is not in the list, throw an exception + if ($ExpectedPurpose -notin $purposes) { + throw ($PodeLocale.certificateNotValidForPurposeExceptionMessage -f $ExpectedPurpose, ($purposes -join ', ')) + } + + # If strict mode is enabled, fail if there are any unknown EKUs + if ($Strict -and ($purposes -match '^Unknown')) { + throw ($PodeLocale.certificateUnknownEkusStrictModeExceptionMessage -f ($purposes -join ', ')) + } + + # Certificate is valid for the expected purpose + Write-Verbose "Certificate is valid for '$ExpectedPurpose'. Found purposes: $($purposes -join ', ')" + +} diff --git a/src/Private/Context.ps1 b/src/Private/Context.ps1 index 19d4c1004..efe26f317 100644 --- a/src/Private/Context.ps1 +++ b/src/Private/Context.ps1 @@ -55,6 +55,9 @@ function New-PodeContext { [string] $ConfigFile, + [string] + $ApplicationName, + [switch] $Daemon ) @@ -100,6 +103,14 @@ function New-PodeContext { $ctx.Server.PodeModule = (Get-PodeModuleInfo) $ctx.Server.Console = $Console $ctx.Server.ComputerName = [System.Net.DNS]::GetHostName() + try { + $ctx.Server.Fqdn = [System.Net.Dns]::GetHostEntry($ctx.Server.ComputerName).HostName + } + catch { + $ctx.Server.Fqdn = $ctx.Server.ComputerName + } + $ctx.Server.ApplicationName = $ApplicationName + # list of created listeners/receivers $ctx.Listeners = @() diff --git a/src/Private/Cryptography.ps1 b/src/Private/Cryptography.ps1 index 8ac0b86d0..9bad032c6 100644 --- a/src/Private/Cryptography.ps1 +++ b/src/Private/Cryptography.ps1 @@ -1,3 +1,4 @@ +using namespace System.Security.Cryptography <# .SYNOPSIS Computes an HMAC-SHA256 hash for a given value using a secret key. @@ -62,135 +63,6 @@ function Invoke-PodeHMACSHA256Hash { return [System.Convert]::ToBase64String($crypto.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($Value))) } -<# -.SYNOPSIS - Computes a private HMAC-SHA384 hash for a given value using a secret key. - -.DESCRIPTION - This function calculates a private HMAC-SHA384 hash for the specified value using either a secret provided as a string or as a byte array. It supports two parameter sets: - 1. String: The secret is provided as a string. - 2. Bytes: The secret is provided as a byte array. - -.PARAMETER Value - The value for which the private HMAC-SHA384 hash needs to be computed. - -.PARAMETER Secret - The secret key as a string. If this parameter is provided, it will be converted to a byte array. - -.PARAMETER SecretBytes - The secret key as a byte array. If this parameter is provided, it will be used directly. - -.OUTPUTS - Returns the computed private HMAC-SHA384 hash as a base64-encoded string. - -.EXAMPLE - $value = "MySecretValue" - $secret = "MySecretKey" - $hash = Invoke-PodeHMACSHA384Hash -Value $value -Secret $secret - Write-PodeHost "Private HMAC-SHA384 hash: $hash" - - This example computes the private HMAC-SHA384 hash for the value "MySecretValue" using the secret key "MySecretKey". - -.NOTES - - This function is intended for internal use. -#> -function Invoke-PodeHMACSHA384Hash { - [CmdletBinding(DefaultParameterSetName = 'String')] - [OutputType([String])] - param( - [Parameter(Mandatory = $true)] - [string] - $Value, - - [Parameter(Mandatory = $true, ParameterSetName = 'String')] - [string] - $Secret, - - [Parameter(Mandatory = $true, ParameterSetName = 'Bytes')] - [byte[]] - $SecretBytes - ) - - # Convert secret to byte array if provided as a string - if (![string]::IsNullOrWhiteSpace($Secret)) { - $SecretBytes = [System.Text.Encoding]::UTF8.GetBytes($Secret) - } - - # Validate secret length - if ($SecretBytes.Length -eq 0) { - # No secret supplied for HMAC384 hash - throw ($PodeLocale.noSecretForHmac384ExceptionMessage) - } - - # Compute private HMAC-SHA384 hash - $crypto = [System.Security.Cryptography.HMACSHA384]::new($SecretBytes) - return [System.Convert]::ToBase64String($crypto.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($Value))) -} - -<# -.SYNOPSIS - Computes a private HMAC-SHA512 hash for a given value using a secret key. - -.DESCRIPTION - This function calculates a private HMAC-SHA512 hash for the specified value using either a secret provided as a string or as a byte array. It supports two parameter sets: - 1. String: The secret is provided as a string. - 2. Bytes: The secret is provided as a byte array. - -.PARAMETER Value - The value for which the private HMAC-SHA512 hash needs to be computed. - -.PARAMETER Secret - The secret key as a string. If this parameter is provided, it will be converted to a byte array. - -.PARAMETER SecretBytes - The secret key as a byte array. If this parameter is provided, it will be used directly. - -.OUTPUTS - Returns the computed private HMAC-SHA512 hash as a base64-encoded string. - -.EXAMPLE - $value = "MySecretValue" - $secret = "MySecretKey" - $hash = Invoke-PodeHMACSHA512Hash -Value $value -Secret $secret - Write-PodeHost "Private HMAC-SHA512 hash: $hash" - - This example computes the private HMAC-SHA512 hash for the value "MySecretValue" using the secret key "MySecretKey". - -.NOTES - - This function is intended for internal use. -#> -function Invoke-PodeHMACSHA512Hash { - [CmdletBinding(DefaultParameterSetName = 'String')] - [OutputType([string])] - param( - [Parameter(Mandatory = $true)] - [string] - $Value, - - [Parameter(Mandatory = $true, ParameterSetName = 'String')] - [string] - $Secret, - - [Parameter(Mandatory = $true, ParameterSetName = 'Bytes')] - [byte[]] - $SecretBytes - ) - - # Convert secret to byte array if provided as a string - if (![string]::IsNullOrWhiteSpace($Secret)) { - $SecretBytes = [System.Text.Encoding]::UTF8.GetBytes($Secret) - } - - # Validate secret length - if ($SecretBytes.Length -eq 0) { - # No secret supplied for HMAC512 hash - throw ($PodeLocale.noSecretForHmac512ExceptionMessage) - } - - # Compute private HMAC-SHA512 hash - $crypto = [System.Security.Cryptography.HMACSHA512]::new($SecretBytes) - return [System.Convert]::ToBase64String($crypto.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($Value))) -} function Invoke-PodeSHA256Hash { [CmdletBinding()] @@ -436,130 +308,4 @@ function ConvertTo-PodeStrictSecret { return "$($Secret);$($WebEvent.Request.UserAgent);$($WebEvent.Request.RemoteEndPoint.Address.IPAddressToString)" } - -function New-PodeJwtSignature { - [CmdletBinding()] - [OutputType([string])] - param( - [Parameter(Mandatory = $true)] - [string] - $Algorithm, - - [Parameter(Mandatory = $true)] - [string] - $Token, - - [Parameter()] - [byte[]] - $SecretBytes - ) - - if (($Algorithm -ine 'none') -and (($null -eq $SecretBytes) -or ($SecretBytes.Length -eq 0))) { - # No secret supplied for JWT signature - throw ($PodeLocale.noSecretForJwtSignatureExceptionMessage) - } - - if (($Algorithm -ieq 'none') -and (($null -ne $secretBytes) -and ($SecretBytes.Length -gt 0))) { - # Expected no secret to be supplied for no signature - throw ($PodeLocale.noSecretExpectedForNoSignatureExceptionMessage) - } - - $sig = $null - - switch ($Algorithm.ToUpperInvariant()) { - 'HS256' { - $sig = Invoke-PodeHMACSHA256Hash -Value $Token -SecretBytes $SecretBytes - $sig = ConvertTo-PodeBase64UrlValue -Value $sig -NoConvert - } - - 'HS384' { - $sig = Invoke-PodeHMACSHA384Hash -Value $Token -SecretBytes $SecretBytes - $sig = ConvertTo-PodeBase64UrlValue -Value $sig -NoConvert - } - - 'HS512' { - $sig = Invoke-PodeHMACSHA512Hash -Value $Token -SecretBytes $SecretBytes - $sig = ConvertTo-PodeBase64UrlValue -Value $sig -NoConvert - } - - 'NONE' { - $sig = [string]::Empty - } - - default { - throw ($PodeLocale.unsupportedJwtAlgorithmExceptionMessage -f $Algorithm) #"The JWT algorithm is not currently supported: $($Algorithm)" - } - } - - return $sig -} - -function ConvertTo-PodeBase64UrlValue { - [CmdletBinding()] - [OutputType([string])] - param( - [Parameter(Mandatory = $true)] - [string] - $Value, - - [switch] - $NoConvert - ) - - if (!$NoConvert) { - $Value = [System.Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($Value)) - } - - $Value = ($Value -ireplace '\+', '-') - $Value = ($Value -ireplace '/', '_') - $Value = ($Value -ireplace '=', '') - - return $Value -} - -function ConvertFrom-PodeJwtBase64Value { - [CmdletBinding()] - [OutputType([pscustomobject])] - param( - [Parameter(Mandatory = $true)] - [string] - $Value - ) - - # map chars - $Value = ($Value -ireplace '-', '+') - $Value = ($Value -ireplace '_', '/') - - # add padding - switch ($Value.Length % 4) { - 1 { - $Value = $Value.Substring(0, $Value.Length - 1) - } - - 2 { - $Value += '==' - } - - 3 { - $Value += '=' - } - } - - # convert base64 to string - try { - $Value = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($Value)) - } - catch { - # Invalid Base64 encoded value found in JWT - throw ($PodeLocale.invalidBase64JwtExceptionMessage) - } - - # return json - try { - return ($Value | ConvertFrom-Json) - } - catch { - # Invalid JSON value found in JWT - throw ($PodeLocale.invalidJsonJwtExceptionMessage) - } -} \ No newline at end of file + diff --git a/src/Private/Helpers.ps1 b/src/Private/Helpers.ps1 index 804e5e7d2..da0b7efb2 100644 --- a/src/Private/Helpers.ps1 +++ b/src/Private/Helpers.ps1 @@ -1516,12 +1516,16 @@ function ConvertFrom-PodeRequestContent { $Content = $Request.Body } } - + # Add raw body content + $Result.RawData = $Content # if there is no content then do nothing if ([string]::IsNullOrWhiteSpace($Content)) { return $Result } + # Add raw body content + $Result.RawData = $Content + # check if there is a defined custom body parser if ($PodeContext.Server.BodyParsers.ContainsKey($ContentType)) { $parser = $PodeContext.Server.BodyParsers[$ContentType] @@ -1530,7 +1534,6 @@ function ConvertFrom-PodeRequestContent { return $Result } } - # run action for the content type switch ($ContentType) { { $_ -ilike '*/json' } { @@ -1944,31 +1947,146 @@ function Convert-PodePathPatternsToRegex { <# .SYNOPSIS - Gets the default SSL protocol(s) based on the operating system. + Determines the default allowed SSL/TLS protocols based on the operating system. .DESCRIPTION - This function determines the appropriate default SSL protocol(s) based on the operating system. On macOS, it returns TLS 1.2. On other platforms, it combines SSL 3.0 and TLS 1.2. + This function detects the operating system and determines the allowed SSL/TLS protocols + based on the system’s native support. The function returns an array of + [System.Security.Authentication.SslProtocols] enum values representing the supported protocols. .OUTPUTS - A [System.Security.Authentication.SslProtocols] enum value representing the default SSL protocol(s). + A [System.Security.Authentication.SslProtocols] enum array containing the allowed SSL/TLS protocols. .EXAMPLE - Get-PodeDefaultSslProtocol - # Returns [System.Security.Authentication.SslProtocols]::Ssl3, [System.Security.Authentication.SslProtocols]::Tls12 (on non-macOS systems) - # Returns [System.Security.Authentication.SslProtocols]::Tls12 (on macOS) + Get-PodeDefaultSslProtocol + [System.Security.Authentication.SslProtocols]::Tls12, [System.Security.Authentication.SslProtocols]::Tls13 .NOTES - This is an internal function and may change in future releases of Pode. + This is an internal function and may change in future releases of Pode. + Overriding the default allowed protocols in configuration does not guarantee their availability. + If a protocol is not natively supported by the OS, additional OS-level configuration may be required. #> function Get-PodeDefaultSslProtocol { [CmdletBinding()] [OutputType([System.Security.Authentication.SslProtocols])] param() - if (Test-PodeIsMacOS) { - return (ConvertTo-PodeSslProtocol -Protocol Tls12) + # Cross-platform detection in PowerShell 7.x + $AllowedProtocols = @() + + if (Test-PodeIsWindows) { + # Retrieve Windows OS info + $osInfo = Get-ItemProperty -Path 'HKLM:\SOFTWARE\Microsoft\Windows NT\CurrentVersion' ` + | Select-Object CurrentBuild, CurrentMajorVersionNumber, CurrentMinorVersionNumber + + $osVersion = [version]"$($osInfo.CurrentMajorVersionNumber).$($osInfo.CurrentMinorVersionNumber).$($osInfo.CurrentBuild)" + + Write-Verbose "Detected OS Version: $osVersion" + + # Determine allowed protocols based on Windows version + if ($osVersion.Major -eq 6 -and $osVersion.Minor -eq 0) { + # Windows Vista / Server 2008 + $AllowedProtocols = @('Ssl2', 'Ssl3') + } + elseif ($osVersion.Major -eq 6 -and $osVersion.Minor -eq 1) { + # Windows 7 / Server 2008 R2 + $AllowedProtocols = @('Ssl2', 'Ssl3') + } + elseif ($osVersion.Major -eq 6 -and $osVersion.Minor -eq 2) { + # Windows 8 / Server 2012 + $AllowedProtocols = @('Ssl3', 'Tls', 'Tls11', 'Tls12') + } + elseif ($osVersion.Major -eq 6 -and $osVersion.Minor -eq 3) { + # Windows 8.1 / Server 2012 R2 + $AllowedProtocols = @('Ssl3', 'Tls', 'Tls11', 'Tls12') + } + elseif ($osVersion.Major -eq 10 -and $osVersion.Build -lt 20170) { + # Windows 10 (Older builds) + $AllowedProtocols = @('Tls', 'Tls11', 'Tls12') + } + elseif ($osVersion.Major -eq 10 -and $osVersion.Build -ge 20170) { + # Windows 10 (Newer builds with potential TLS 1.3 support) + $AllowedProtocols = @('Tls', 'Tls11', 'Tls12', 'Tls13') + } + elseif ($osVersion.Major -ge 10 -and $osVersion.Build -ge 22000) { + # Windows 11 / Server 2022 (Modern protocol set) + $AllowedProtocols = @('Tls12', 'Tls13') + } + else { + Write-Warning 'Unknown Windows version. Defaulting to modern protocols.' + $AllowedProtocols = @('Tls', 'Tls11', 'Tls12') + } + } + elseif ($IsMacOS) { + # Use sw_vers to get macOS version info + $osName = $(sw_vers -productName) + $productVersion = $(sw_vers -productVersion).Trim() + Write-Verbose "Detected OS: $osName, Version: $productVersion" + $versionObj = [version]$productVersion + + # Determine allowed protocols for macOS + if ($versionObj -lt [version]'10.11') { + # macOS 10.8 - 10.10: SSL3 allowed, TLS 1.0/1.1/1.2 allowed, TLS1.3 not supported + $AllowedProtocols = @('Ssl3', 'Tls', 'Tls11', 'Tls12') + } + elseif ($versionObj -ge [version]'10.11' -and $versionObj -lt [version]'10.13') { + # macOS 10.11 (and likely 10.12): SSL3 disabled, TLS 1.0/1.1/1.2 allowed + $AllowedProtocols = @('Tls', 'Tls11', 'Tls12') + } + else { + # macOS 10.13 and later: TLS 1.3 is supported in addition to TLS 1.0, 1.1, 1.2 + $AllowedProtocols = @('Tls', 'Tls11', 'Tls12', 'Tls13') + } + } + elseif ($IsLinux) { + # Read /etc/os-release for OS info if available + if (Test-Path '/etc/os-release') { + $osRelease = Get-Content '/etc/os-release' | ConvertFrom-StringData + $osName = $osRelease.NAME + $osVersion = $osRelease.VERSION_ID + Write-Verbose "Detected OS: $osName, Version: $osVersion" + } + else { + $osName = 'Linux' + Write-Verbose "Detected OS: $osName" + } + + # Determine allowed protocols based on the installed OpenSSL version. + try { + $opensslOutput = openssl version 2>&1 + if ($opensslOutput -match 'OpenSSL\s+([\d\.]+)') { + $opensslVersion = [version]$matches[1] + Write-Verbose "Detected OpenSSL version: $opensslVersion" + if ($opensslVersion -ge [version]'1.1.1') { + # OpenSSL 1.1.1 and later support TLS 1.3 + $AllowedProtocols = @('Tls', 'Tls11', 'Tls12', 'Tls13') + } + elseif ($opensslVersion -ge [version]'1.0.1g') { + # OpenSSL 1.0.1g up to before 1.1.1 disable SSL3 + $AllowedProtocols = @('Tls', 'Tls11', 'Tls12') + } + else { + # OpenSSL 1.0.1 to 1.0.1f: SSL3 is allowed along with TLS 1.0/1.1/1.2 + $AllowedProtocols = @('Ssl3', 'Tls', 'Tls11', 'Tls12') + } + } + else { + Write-Warning 'Could not parse OpenSSL version. Defaulting to TLS 1.2.' + $AllowedProtocols = @('Tls', 'Tls11', 'Tls12') + } + } + catch { + Write-Warning 'OpenSSL version check failed. Defaulting to TLS 1.2.' + $AllowedProtocols = @('Tls', 'Tls11', 'Tls12') + } + } + else { + Write-Warning 'Unknown platform. No allowed protocols determined.' + $AllowedProtocols = @('Ssl3', 'Tls12') } - return (ConvertTo-PodeSslProtocol -Protocol Ssl3, Tls12) + Write-Verbose "Allowed protocols: $($AllowedProtocols -join ', ')" + + return (ConvertTo-PodeSslProtocol -Protocol $AllowedProtocols) } <# @@ -3966,3 +4084,165 @@ function ConvertTo-PodeSleep { function Test-PodeIsISEHost { return ((Test-PodeIsWindows) -and ('Windows PowerShell ISE Host' -eq $Host.Name)) } + + +<# +.SYNOPSIS + Retrieves the name of the main Pode application script. + +.DESCRIPTION + The `Get-PodeApplicationName` function determines the name of the primary script (`.ps1`) + that started execution. It does this by examining the PowerShell call stack and + extracting the first script file that appears. + + If no script file is found in the call stack, the function returns `"NoName"`. + +.OUTPUTS + [string] + Returns the filename of the main application script, or `"NoName"` if no script is found. + +.EXAMPLE + Get-PodeApplicationName + + This retrieves the name of the main script that launched the Pode application. + +.EXAMPLE + $AppName = Get-PodeApplicationName + Write-Host "Application Name: $AppName" + + This stores the retrieved application name in a variable and prints it. + +.NOTES + - This function relies on `Get-PSCallStack`, meaning it must be run within a script execution context. + - If called interactively or if no `.ps1` script is in the call stack, it will return `"NoName"`. + - This is an internal function and may change in future releases of Pode. +#> +function Get-PodeApplicationName { + $scriptFrame = (Get-PSCallStack | Where-Object { $_.Command -match '\.ps1$' } | Select-Object -First 1) + if ($scriptFrame) { + return [System.IO.Path]::GetFileNameWithoutExtension($scriptFrame.Command) + + } + else { + return 'NoName' + } +} + +<# + .SYNOPSIS + Displays a deprecation warning message for a function. + + .DESCRIPTION + The Write-PodeDeprecationWarning function generates a warning message indicating that + a specified function is deprecated and suggests the new replacement function. + + .PARAMETER OldFunction + The name of the deprecated function that is being replaced. + + .PARAMETER NewFunction + The name of the new function that should be used instead. + + .OUTPUTS + None. + + .EXAMPLE + Write-PodeDeprecationWarning -OldFunction "New-PodeLoggingMethod" -NewFunction "New-PodeLogger" + + This will display: + WARNING: Function `New-PodeLoggingMethod` is deprecated. Please use 'New-PodeLogger' function instead. + + .NOTES + Internal function for Pode. + Subject to change in future releases. +#> +function Write-PodeDeprecationWarning { + param ( + [Parameter(Mandatory = $true)] + [string] + $OldFunction, + + [Parameter(Mandatory = $true)] + [string] + $NewFunction + ) + # WARNING: Function `New-PodeLoggingMethod` is deprecated. Please use '{0}' function instead. + Write-PodeHost ($PodeLocale.deprecatedFunctionWarningMessage -f $OldFunction, $NewFunction) -ForegroundColor Yellow +} + +<# +.SYNOPSIS + Converts a SecureString to plain text. + +.DESCRIPTION + This function takes a SecureString input and converts it into a plain text string. + Supports pipeline input for seamless integration with other cmdlets. + +.PARAMETER SecureString + The SecureString that needs to be converted. + +.OUTPUTS + [string] Plain text representation of the SecureString. + +.NOTES + Internal Pode function - subject to change. +#> +function Convert-PodeSecureStringToPlainText { + [CmdletBinding()] + [OutputType([string])] + param ( + [Parameter(Mandatory = $true, ValueFromPipeline)] + [securestring]$SecureString + ) + + process { + $bstr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($SecureString) + try { + [Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr) + } + finally { + [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr) + } + } +} + +<# +.SYNOPSIS + Converts a SecureString to a UTF8 byte array. + +.DESCRIPTION + This function takes a SecureString input and converts it into a UTF8 encoded byte array. + Supports pipeline input for seamless integration with other cmdlets. + +.PARAMETER SecureString + The SecureString that needs to be converted. + +.OUTPUTS + [byte[]] A UTF8 encoded byte array representation of the SecureString. + +.NOTES + Internal Pode function - subject to change. +#> +function Convert-PodeSecureStringToByteArray { + [CmdletBinding()] + [OutputType([byte[]])] + param ( + [Parameter(Mandatory = $true, ValueFromPipeline)] + [securestring] + $SecureString + ) + + process { + if ($null -ne $SecureString) { + $bstr = [Runtime.InteropServices.Marshal]::SecureStringToBSTR($SecureString) + try { + [System.Text.Encoding]::UTF8.GetBytes([Runtime.InteropServices.Marshal]::PtrToStringBSTR($bstr)) + } + finally { + [Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr) + } + } + else { + return [byte[]]::new(0) # Return empty byte array instead of $null + } + } +} diff --git a/src/Private/Jwt.ps1 b/src/Private/Jwt.ps1 new file mode 100644 index 000000000..e8a41c3a8 --- /dev/null +++ b/src/Private/Jwt.ps1 @@ -0,0 +1,918 @@ + +<# +.SYNOPSIS + Validates and verifies the authenticity of a JSON Web Token (JWT). + + .DESCRIPTION + This function validates a JWT by: + - Splitting and decoding the token. + - Verifying the algorithm used. + - Performing signature validation using HMAC, RSA, or ECDSA. + - Supporting configurable verification modes. + - Returning the payload if valid. + + .PARAMETER Token + The JWT string to be validated in `header.payload.signature` format. + + .PARAMETER Algorithm + Supported JWT signing algorithms: HS256, RS256, ES256, etc. + + .PARAMETER Secret + SecureString key for HMAC algorithms. + + .PARAMETER X509Certificate + X509Certificate2 object for RSA/ECDSA verification. + + .OUTPUTS + Returns the JWT payload if the token is valid. + + .EXAMPLE + Confirm-PodeJwt -Token $jwt -Algorithm RS256 -Certificate $cert + +.NOTES + - Throws an exception if the JWT is invalid, expired, or tampered with. + - The function does not check the `exp`, `nbf`, or `iat` claims. + - Use `Test-PodeJwt` separately to validate JWT claims. +#> +function Confirm-PodeJwt { + [CmdletBinding()] + param ( + [Parameter(Mandatory = $true)] + [string]$Token, + + [Parameter(Mandatory = $true)] + [ValidateSet('NONE', 'HS256', 'HS384', 'HS512', 'RS256', 'RS384', 'RS512', 'PS256', 'PS384', 'PS512', 'ES256', 'ES384', 'ES512')] + [string[]]$Algorithm, + + [Parameter()] + [securestring]$Secret, # Required for HMAC + + [Parameter()] + [System.Security.Cryptography.X509Certificates.X509Certificate2] + $X509Certificate + ) + + # Split JWT into header, payload, and signature + $parts = $Token -split '\.' + if (($parts.Length -ne 3)) { + throw ($PodeLocale.invalidJwtSuppliedExceptionMessage) + } + + # Decode the JWT header + $header = ConvertFrom-PodeJwtBase64Value -Value $parts[0] + + # Decode the JWT payload + $payload = ConvertFrom-PodeJwtBase64Value -Value $parts[1] + + # Apply verification mode for algorithm enforcement + if ($Algorithm -notcontains $header.alg) { + throw ($PodeLocale.jwtAlgorithmMismatchExceptionMessage -f ($Algorithm -join ','), $header.alg) + } + + $Algorithm = $header.alg + + # Handle none algorithm cases + $isNoneAlg = ($header.alg -eq 'NONE') + if ([string]::IsNullOrEmpty($Algorithm)) { + throw ($PodeLocale.noAlgorithmInJwtHeaderExceptionMessage) + } + + # Ensure secret/certificate presence when required + if (($null -eq $Secret) -and ( $null -eq $X509Certificate) -and !$isNoneAlg) { + # No JWT signature supplied for {0} + throw ($PodeLocale.noJwtSignatureForAlgorithmExceptionMessage -f $header.alg) + } + if ((( $null -ne $X509Certificate) -or ($null -ne $Secret)) -and $isNoneAlg) { + # Expected no JWT signature to be supplied + throw ($PodeLocale.expectedNoJwtSignatureSuppliedExceptionMessage) + } + + if ((![string]::IsNullOrEmpty($parts[2]) -and $isNoneAlg)) { + throw ($PodeLocale.invalidJwtSuppliedExceptionMessage) + } + + if ($isNoneAlg) { + return $payload + } + if ($null -ne $Secret) { + # Convert Secret to bytes if provided + $secretBytes = Convert-PodeSecureStringToByteArray -SecureString $Secret + } + + if ($isNoneAlg -and ($null -ne $SecretBytes) -and ($SecretBytes.Length -gt 0)) { + # Expected no JWT signature to be supplied + throw ($PodeLocale.expectedNoJwtSignatureSuppliedExceptionMessage) + } + + # Prepare data for signature verification + $headerPayloadBytes = [System.Text.Encoding]::UTF8.GetBytes("$($parts[0]).$($parts[1])") + # Convert JWT signature from Base64 URL to Byte Array + $fixedSignature = $parts[2].Replace('-', '+').Replace('_', '/') + # Add proper Base64 padding + switch ($fixedSignature.Length % 4) { + 1 { $fixedSignature = $fixedSignature.Substring(0, $fixedSignature.Length - 1); break } # Remove invalid character + 2 { $fixedSignature += '=='; break } # Add two padding characters + 3 { $fixedSignature += '='; break } # Add one padding character + } + $signatureBytes = [Convert]::FromBase64String($fixedSignature) + + # Verify Signature + + # Handle HMAC signature verification + if ($Algorithm -match '^HS(\d{3})$') { + if ($null -eq $SecretBytes) { + throw ($PodeLocale.missingKeyForAlgorithmExceptionMessage -f 'secret', 'HMAC', $Algorithm) + } + + # Compute HMAC Signature + $hmac = switch ($Algorithm) { + 'HS256' { [System.Security.Cryptography.HMACSHA256]::new($SecretBytes); break } + 'HS384' { [System.Security.Cryptography.HMACSHA384]::new($SecretBytes); break } + 'HS512' { [System.Security.Cryptography.HMACSHA512]::new($SecretBytes); break } + } + # Prepare JWT signing input + $expectedSignatureBytes = $hmac.ComputeHash([System.Text.Encoding]::UTF8.GetBytes("$($parts[0]).$($parts[1])")) + $expectedSignature = [Convert]::ToBase64String($expectedSignatureBytes).Replace('+', '-').Replace('/', '_').TrimEnd('=') + + # Compare signatures + if ($expectedSignature -ne $parts[2]) { + throw ($PodeLocale.invalidJwtSignatureSuppliedExceptionMessage) + } + } + elseif ($Algorithm -match '^(RS|PS)(\d{3})$') { + # Extract the RSA public key from the existing certificate object + $rsa = [System.Security.Cryptography.X509Certificates.RSACertificateExtensions]::GetRSAPublicKey($X509Certificate) + + $hashAlgo = switch ($Algorithm) { + 'RS256' { [System.Security.Cryptography.HashAlgorithmName]::SHA256; break } + 'RS384' { [System.Security.Cryptography.HashAlgorithmName]::SHA384; break } + 'RS512' { [System.Security.Cryptography.HashAlgorithmName]::SHA512; break } + 'PS256' { [System.Security.Cryptography.HashAlgorithmName]::SHA256; break } + 'PS384' { [System.Security.Cryptography.HashAlgorithmName]::SHA384; break } + 'PS512' { [System.Security.Cryptography.HashAlgorithmName]::SHA512; break } + } + + $rsaPadding = if ($Algorithm -match '^PS') { + [System.Security.Cryptography.RSASignaturePadding]::Pss + } + else { + [System.Security.Cryptography.RSASignaturePadding]::Pkcs1 + } + if (!($rsa.VerifyData($headerPayloadBytes, $signatureBytes, $hashAlgo, $rsaPadding))) { + throw ($PodeLocale.invalidJwtSignatureSuppliedExceptionMessage) + } + } + elseif ($Algorithm -match '^ES(\d{3})$') { + # Extract the ECSDA public key from the existing certificate object + $ecdsa = [System.Security.Cryptography.X509Certificates.ECDsaCertificateExtensions]::GetECDsaPrivateKey($X509Certificate) + + $hashAlgo = switch ($Algorithm) { + 'ES256' { [System.Security.Cryptography.HashAlgorithmName]::SHA256; break } + 'ES384' { [System.Security.Cryptography.HashAlgorithmName]::SHA384; break } + 'ES512' { [System.Security.Cryptography.HashAlgorithmName]::SHA512; break } + } + if (!($ecdsa.VerifyData($headerPayloadBytes, $signatureBytes, $hashAlgo))) { + throw ($PodeLocale.invalidJwtSignatureSuppliedExceptionMessage) + } + } + + return $payload +} + +function ConvertTo-PodeBase64UrlValue { + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter(Mandatory = $true)] + [string] + $Value, + + [switch] + $NoConvert + ) + + if (!$NoConvert) { + $Value = [System.Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes($Value)) + } + + return $Value.Replace('+', '-').Replace('/', '_').TrimEnd('=') +} + +function ConvertFrom-PodeJwtBase64Value { + [CmdletBinding()] + [OutputType([pscustomobject])] + param( + [Parameter(Mandatory = $true)] + [string] + $Value + ) + + # map chars + $Value = $Value.Replace('-', '+').Replace('_', '/') + # Add proper Base64 padding + switch ($Value.Length % 4) { + 1 { $Value = $Value.Substring(0, $Value.Length - 1) } # Remove invalid character + 2 { $Value += '==' } # Add two padding characters + 3 { $Value += '=' } # Add one padding character + } + # convert base64 to string + try { + $Value = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($Value)) + } + catch { + # Invalid Base64 encoded value found in JWT + throw ($PodeLocale.invalidBase64JwtExceptionMessage) + } + # return json + try { + return ($Value | ConvertFrom-Json) + } + catch { + # Invalid JSON value found in JWT + throw ($PodeLocale.invalidJsonJwtExceptionMessage) + } +} + +<# +.SYNOPSIS + Computes a cryptographic hash using the specified algorithm. + +.DESCRIPTION + This function accepts a string and an algorithm name, computes the hash using the specified algorithm, + and returns the hash as a lowercase hexadecimal string. + +.PARAMETER Value + The input string to be hashed. + +.PARAMETER Algorithm + The hashing algorithm to use (SHA-1, SHA-256, SHA-512, SHA-512/256). + +.OUTPUTS + [string] - The computed hash in hexadecimal format. + +.NOTES + Internal Pode function for authentication hashing. +#> +function ConvertTo-PodeDigestHash { + [CmdletBinding()] + [OutputType([string])] + param( + [Parameter()] + $Value, + + [Parameter(Mandatory = $true)] + [ValidateSet('MD5', 'SHA-1', 'SHA-256', 'SHA-512', 'SHA-384', 'SHA-512/256')] + [string] + $Algorithm + ) + + # Select the appropriate hash algorithm + $crypto = switch ($Algorithm) { + 'MD5' { [System.Security.Cryptography.MD5]::Create(); break } + 'SHA-1' { [System.Security.Cryptography.SHA1]::Create(); break } + 'SHA-256' { [System.Security.Cryptography.SHA256]::Create(); break } + 'SHA-384' { [System.Security.Cryptography.SHA384]::Create(); break } + 'SHA-512' { [System.Security.Cryptography.SHA512]::Create(); break } + 'SHA-512/256' { + # Compute SHA-512 and truncate to 256 bits (first 32 bytes) + $sha512 = [System.Security.Cryptography.SHA512]::Create() + $fullHash = $sha512.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($Value)) + return [System.BitConverter]::ToString($fullHash[0..31]).Replace('-', '').ToLowerInvariant() + } + } + + return [System.BitConverter]::ToString($crypto.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($Value))).Replace('-', '').ToLowerInvariant() +} + +<# +.SYNOPSIS + Determines the JWT signing algorithm based on the provided X.509 certificate. + +.DESCRIPTION + This function extracts the private key (RSA or ECDSA) from a given X.509 certificate (PFX) and determines the appropriate JSON Web Token (JWT) signing algorithm. + For RSA keys, the function attempts to read the key size using the `KeySize` property. On Linux with .NET 9, this property is write-only, so a reflection-based workaround is used to retrieve the private `KeySizeValue` field. + For ECDSA keys, the algorithm is selected directly based on the key size. + +.PARAMETER X509Certificate + A System.Security.Cryptography.X509Certificates.X509Certificate2 object representing the certificate (PFX) from which the private key is extracted. + +.PARAMETER RsaPaddingScheme + Specifies the RSA padding scheme to use. Acceptable values are 'Pkcs1V15' (default) and 'Pss'. + +.EXAMPLE + PS> Get-PodeJwtSigningAlgorithm -X509Certificate $myCert -RsaPaddingScheme 'Pkcs1V15' + Determines and returns the appropriate JWT signing algorithm (e.g., 'RS256', 'RS384', 'RS512' for RSA or 'ES256', 'ES384', 'ES512' for ECDSA) based on the certificate's key. + +.NOTES + This function includes a reflection-based workaround for .NET 9 on Linux where the RSA `KeySize` property is write-only. Refer to https://github.com/dotnet/runtime/issues/112622 for more details. +#> +function Get-PodeJwtSigningAlgorithm { + param ( + + [System.Security.Cryptography.X509Certificates.X509Certificate2] + $X509Certificate, # PFX + + [ValidateSet('Pkcs1V15', 'Pss')] + [string]$RsaPaddingScheme = 'Pkcs1V15' # Default to PKCS#1 v1.5 unless specified + ) + # Extract Private Key (RSA or ECDSA) + $key = [System.Security.Cryptography.X509Certificates.RSACertificateExtensions]::GetRSAPrivateKey($X509Certificate) + if ($null -ne $key) { + Write-Verbose 'RSA Private Key detected.' + try { + $keySize = $key.KeySize + } + catch { + # Exception is 'Cannot get property value because "KeySize" is a write-only property.' + # Use reflection to access the private 'KeySizeValue' field + $bindingFlags = [System.Reflection.BindingFlags] 'NonPublic, Instance' + $keySizeField = $key.GetType().GetField('KeySizeValue', $bindingFlags) + + # Retrieve the value of the 'KeySizeValue' field this is a workaround of an issue with .net for Linux + Write-Verbose "Keysize obtained by reflection $($keySizeField.GetValue($key))" + $keySize = $keySizeField.GetValue($key) + } + # Determine RSA key size + switch ($keySize) { + 2048 { return $(if ($RsaPaddingScheme -eq 'Pkcs1V15') { 'RS256' } else { 'PS256' }) } + 3072 { return $(if ($RsaPaddingScheme -eq 'Pkcs1V15') { 'RS384' } else { 'PS384' }) } + 4096 { return $(if ($RsaPaddingScheme -eq 'Pkcs1V15') { 'RS512' } else { 'PS512' }) } + default { throw ($PodeLocale.unknownAlgorithmWithKeySizeExceptionMessage -f 'RSA', $rsa.KeySize) } + } + } + else { + $key = [System.Security.Cryptography.X509Certificates.ECDsaCertificateExtensions]::GetECDsaPrivateKey($X509Certificate) + if ($null -ne $key) { + Write-Verbose 'ECDSA Private Key detected.' + + # Determine ECDSA key size + switch ($key.KeySize) { + 256 { return 'ES256' } + 384 { return 'ES384' } + 521 { return 'ES512' } # JWT uses 521-bit, NOT 512-bit + default { throw ($PodeLocale.unknownAlgorithmWithKeySizeExceptionMessage -f 'ECDSA' , $ecdsa.KeySize) } + } + } + else { + throw $PodeLocale.unknownAlgorithmOrInvalidPfxExceptionMessage + } + } +} + + + + +<# +.SYNOPSIS + Generates a JSON Web Token (JWT) based on the specified headers, payload, and signing credentials. +.DESCRIPTION + This function creates a JWT by combining a Base64URL-encoded header and payload. Depending on the + configured parameters, it supports various signing algorithms, including HMAC- and certificate-based + signatures. You can also omit a signature by specifying 'none'. + +.PARAMETER Header + Additional header values for the JWT. Defaults to an empty hashtable if not specified. + +.PARAMETER Payload + The required hashtable specifying the token’s claims. + +.PARAMETER Algorithm + A string representing the signing algorithm to be used. Accepts 'NONE', 'HS256', 'HS384', or 'HS512'. + +.PARAMETER Secret + Used in conjunction with HMAC signing. Can be either a byte array or a SecureString. Required if you + select the 'SecretBytes' parameter set. + +.PARAMETER X509Certificate + An X509Certificate2 object used for RSA/ECDSA-based signing. Required if you select the 'CertRaw' parameter set. + +.PARAMETER Certificate + The path to a certificate file used for signing. Required if you select the 'CertFile' parameter set. + +.PARAMETER PrivateKeyPath + Optional path to an associated certificate key file. + +.PARAMETER CertificatePassword + An optional SecureString password for a certificate file. + +.PARAMETER CertificateThumbprint + A string thumbprint of a certificate in the local store. Required if you select the 'CertThumb' parameter set. + +.PARAMETER CertificateName + A string name of a certificate in the local store. Required if you select the 'CertName' parameter set. + +.PARAMETER CertificateStoreName + The store name to search for the specified certificate. Defaults to 'My'. + +.PARAMETER CertificateStoreLocation + The certificate store location for the specified certificate. Defaults to 'CurrentUser'. + +.PARAMETER RsaPaddingScheme + Specifies the RSA padding scheme to use. Accepts 'Pkcs1V15' or 'Pss'. Defaults to 'Pkcs1V15'. + +.PARAMETER Authentication + The name of a configured authentication method in Pode. Required if you select the 'AuthenticationMethod' parameter set. + +.PARAMETER Expiration + Time in seconds until the token expires. Defaults to 3600 (1 hour). + +.PARAMETER NotBefore + Time in seconds to offset the NotBefore claim. Defaults to 0 for immediate use. + +.PARAMETER IssuedAt + Time in seconds to offset the IssuedAt claim. Defaults to 0 for current time. + +.PARAMETER Issuer + Identifies the principal that issued the token. + +.PARAMETER Subject + Identifies the principal that is the subject of the token. + +.PARAMETER Audience + Specifies the recipients that the token is intended for. + +.PARAMETER JwtId + A unique identifier for the token. + +.PARAMETER NoStandardClaims + A switch that, if used, prevents automatically adding iat, nbf, exp, iss, sub, aud, and jti claims. + +.OUTPUTS + System.String + The resulting JWT string. + + +.EXAMPLE + New-PodeJwt -Header [pscustomobject]@{ alg = 'none' } -Payload [pscustomobject]@{ sub = '123'; name = 'John' } + +.EXAMPLE + New-PodeJwt -Header [pscustomobject]@{ alg = 'HS256' } -Payload [pscustomobject]@{ sub = '123'; name = 'John' } -Secret 'abc' + +.EXAMPLE + New-PodeJwt -Header [pscustomobject]@{ alg = 'RS256' } -Payload [pscustomobject]@{ sub = '123' } -PrivateKey (Get-Content "private.pem" -Raw) -Issuer "auth.example.com" -Audience "myapi.example.com" +#> +function New-PodeJwt { + [CmdletBinding(DefaultParameterSetName = 'Default')] + [OutputType([string])] + param( + [Parameter()] + [pscustomobject]$Header, + + [Parameter(Mandatory = $true)] + [pscustomobject]$Payload, + + [Parameter(ParameterSetName = 'Default')] + [Parameter(ParameterSetName = 'Secret')] + [ValidateSet('NONE', 'HS256', 'HS384', 'HS512')] + [string]$Algorithm, + + [Parameter(Mandatory = $true, ParameterSetName = 'Secret')] + [byte[]] + $Secret = $null, + + [Parameter(Mandatory = $true, ParameterSetName = 'CertRaw')] + [System.Security.Cryptography.X509Certificates.X509Certificate2] + $X509Certificate, + + [Parameter(Mandatory = $true, ParameterSetName = 'CertFile')] + [string] + $Certificate, + + [Parameter(Mandatory = $false, ParameterSetName = 'CertFile')] + [string] + $PrivateKeyPath = $null, + + [Parameter(Mandatory = $false, ParameterSetName = 'CertFile')] + [SecureString] + $CertificatePassword, + + [Parameter(Mandatory = $true, ParameterSetName = 'CertThumb')] + [string] + $CertificateThumbprint, + + [Parameter(Mandatory = $true, ParameterSetName = 'CertName')] + [string] + $CertificateName, + + [Parameter(ParameterSetName = 'CertName')] + [Parameter(ParameterSetName = 'CertThumb')] + [System.Security.Cryptography.X509Certificates.StoreName] + $CertificateStoreName = 'My', + + [Parameter(ParameterSetName = 'CertName')] + [Parameter(ParameterSetName = 'CertThumb')] + [System.Security.Cryptography.X509Certificates.StoreLocation] + $CertificateStoreLocation = 'CurrentUser', + + [Parameter(Mandatory = $false, ParameterSetName = 'CertRaw')] + [Parameter(Mandatory = $false, ParameterSetName = 'CertFile')] + [Parameter(Mandatory = $false, ParameterSetName = 'CertName')] + [Parameter(Mandatory = $false, ParameterSetName = 'CertThumb')] + [ValidateSet('Pkcs1V15', 'Pss')] + [string] + $RsaPaddingScheme = 'Pkcs1V15', + + [Parameter(Mandatory = $true, ParameterSetName = 'AuthenticationMethod')] + [string] + $Authentication, + + [Parameter()] + [int] + $Expiration = 3600, # Default: 1 hour + + [Parameter()] + [int] + $NotBefore = 0, # Default: Immediate + + [Parameter()] + [int]$IssuedAt = 0, # Default: Current time + + [Parameter()] + [string]$Issuer, + + [Parameter()] + [string]$Subject, + + [Parameter()] + [string]$Audience, + + [Parameter()] + [string]$JwtId, + + [Parameter()] + [switch] + $NoStandardClaims + ) + if (!($Header.PSObject.Properties['alg'])) { + $Header | Add-Member -MemberType NoteProperty -Name 'alg' -Value '' + } + + # Determine actions based on parameter set + switch ($PSCmdlet.ParameterSetName) { + 'CertFile' { + if (!(Test-Path -Path $Certificate -PathType Leaf)) { + throw ($PodeLocale.pathNotExistExceptionMessage -f $Certificate) + } + + # Retrieve X509 certificate from a file + $X509Certificate = Get-PodeCertificateByFile -Certificate $Certificate -SecurePassword $CertificatePassword -PrivateKeyPath $PrivateKeyPath + break + } + + 'certthumb' { + # Retrieve X509 certificate from store by thumbprint + $X509Certificate = Get-PodeCertificateByThumbprint -Thumbprint $CertificateThumbprint -StoreName $CertificateStoreName -StoreLocation $CertificateStoreLocation + } + + 'certname' { + # Retrieve X509 certificate from store by name + $X509Certificate = Get-PodeCertificateByName -Name $CertificateName -StoreName $CertificateStoreName -StoreLocation $CertificateStoreLocation + } + + 'Secret' { + # If algorithm was already set in the header, default to it if none provided + if (!([string]::IsNullOrWhiteSpace($Header.alg))) { + if ([string]::IsNullOrWhiteSpace($Algorithm)) { + $Algorithm = $Header.alg.ToUpper() + } + } + + # Validate that 'none' has no secret + if (($Algorithm -ieq 'none')) { + throw ($PodeLocale.noSecretExpectedForNoSignatureExceptionMessage) + } + + # Convert secret to a byte array if needed + if ($null -eq $Secret) { + throw ($PodeLocale.missingKeyForAlgorithmExceptionMessage -f 'secret', 'HMAC', $Header.alg) + } + + + if ([string]::IsNullOrWhiteSpace($Algorithm)) { + $Algorithm = 'HS256' + } + + $Header.alg = $Algorithm.ToUpper() + $params = @{ + Algorithm = $Algorithm.ToUpper() + SecretBytes = $Secret + } + break + } + + 'CertRaw' { + # Validate that a raw certificate is present + if ($null -eq $X509Certificate) { + throw ($PodeLocale.missingKeyForAlgorithmExceptionMessage -f 'private', 'RSA/ECSDA', $Header.alg) + } + break + } + + 'AuthenticationMethod' { + # Retrieve authentication details from Pode's context + if ($PodeContext -and $PodeContext.Server.Authentications.Methods.ContainsKey($Authentication)) { + # If 'none' was set in the header but is not supported by the method, throw + if (($Header.alg -ieq 'none') -and $PodeContext.Server.Authentications.Methods.ContainsKey($Authentication).Algorithm -notcontains 'none') { + throw ($PodeLocale.noSecretExpectedForNoSignatureExceptionMessage) + } + $Header.alg = $PodeContext.Server.Authentications.Methods[$Authentication].Scheme.Arguments.Algorithm[0] + $params = @{ + Authentication = $Authentication + } + } + else { + throw ($PodeLocale.authenticationMethodDoesNotExistExceptionMessage) + } + } + } + + # Configure the JWT header and parameters if using a certificate + if ($null -ne $X509Certificate) { + + # Skip certificate validation if it has been explicitly provided as a variable. + if ($PSCmdlet.ParameterSetName -ne 'CertRaw') { + # Validate that the certificate: + # 1. Is within its validity period. + # 2. Has a valid certificate chain. + # 3. Is explicitly authorized for the expected purpose (Code Signing). + # 4. Meets strict Enhanced Key Usage (EKU) enforcement. + $null = Test-PodeCertificate -Certificate $X509Certificate -ExpectedPurpose CodeSigning -Strict -ErrorAction Stop + } + + $Header.alg = Get-PodeJwtSigningAlgorithm -X509Certificate $X509Certificate -RsaPaddingScheme $RsaPaddingScheme + $params = @{ + X509Certificate = $X509Certificate + RsaPaddingScheme = $RsaPaddingScheme + } + } + + # Optionally add standard claims if not suppressed + if (!$NoStandardClaims) { + if (! $Header.PSObject.Properties['typ']) { + $Header | Add-Member -MemberType NoteProperty -Name 'typ' -Value 'JWT' + } + else { + $Header.typ = 'JWT' + } + + # Current Unix time + $currentUnix = [int][Math]::Floor(([DateTimeOffset]::new([DateTime]::UtcNow)).ToUnixTimeSeconds()) + + if (! $Payload.PSObject.Properties['iat']) { + $Payload | Add-Member -MemberType NoteProperty -Name 'iat' -Value $(if ($IssuedAt -gt 0) { $IssuedAt } else { $currentUnix }) + } + if (! $Payload.PSObject.Properties['nbf']) { + $Payload | Add-Member -MemberType NoteProperty -Name 'nbf' -Value ($currentUnix + $NotBefore) + } + if (! $Payload.PSObject.Properties['exp']) { + $Payload | Add-Member -MemberType NoteProperty -Name 'exp' -Value ($currentUnix + $Expiration) + } + + if (! $Payload.PSObject.Properties['iss']) { + if ([string]::IsNullOrEmpty($Issuer)) { + if ($null -ne $PodeContext) { + $Payload | Add-Member -MemberType NoteProperty -Name 'iss' -Value 'Pode' + } + } + else { + $Payload | Add-Member -MemberType NoteProperty -Name 'iss' -Value $Issuer + } + } + + if (! $Payload.PSObject.Properties['sub'] -and ![string]::IsNullOrEmpty($Subject)) { + $Payload | Add-Member -MemberType NoteProperty -Name 'sub' -Value $Subject + } + + if (! $Payload.PSObject.Properties['aud']) { + if ([string]::IsNullOrEmpty($Audience)) { + if (($null -ne $PodeContext) -and ($null -ne $PodeContext.Server.ApplicationName)) { + $Payload | Add-Member -MemberType NoteProperty -Name 'aud' -Value $PodeContext.Server.ApplicationName + } + } + else { + $Payload | Add-Member -MemberType NoteProperty -Name 'aud' -Value $Audience + } + } + + if (! $Payload.PSObject.Properties['jti'] ) { + if ([string]::IsNullOrEmpty($JwtId)) { + $Payload | Add-Member -MemberType NoteProperty -Name 'jti' -Value (New-PodeGuid) + } + else { + $Payload | Add-Member -MemberType NoteProperty -Name 'jti' -Value $JwtId + } + } + } + + # Encode header and payload as Base64URL + $header64 = ConvertTo-PodeBase64UrlValue -Value ($Header | ConvertTo-Json -Compress) + $payload64 = ConvertTo-PodeBase64UrlValue -Value ($Payload | ConvertTo-Json -Compress) + + # Combine header and payload + $jwt = "$($header64).$($payload64)" + + # Generate signature if not 'none' + $sig = if ($Header.alg -ne 'none') { + $params['Token'] = $jwt + New-PodeJwtSignature @params + } + else { + [string]::Empty + } + + # Concatenate signature to form the final JWT + $jwt += ".$($sig)" + return $jwt +} + + + + + + +<# +.SYNOPSIS + Generates a JWT-compatible signature using a specified RFC 7518 signing algorithm. + +.DESCRIPTION + This function creates a JWT signature for a given token using the provided algorithm and secret key bytes. + It ensures that a secret is supplied when required and throws an exception if constraints are violated. + The signature is computed using HMAC (HS256, HS384, HS512), RSA (RS256, RS384, RS512, PS256, PS384, PS512), or ECDSA (ES256, ES384, ES512). + +.PARAMETER Algorithm + The signing algorithm. Supported values: HS256, HS384, HS512, RS256, RS384, RS512, PS256, PS384, PS512, ES256, ES384, ES512. + +.PARAMETER Token + The JWT token to be signed. + +.PARAMETER SecretBytes + The secret key in byte array format used for signing the JWT using the HMAC algorithms. + This parameter is optional when using the 'none' algorithm. + +.PARAMETER X509Certificate + The private key certificate for RSA or ECDSA algorithms. + +.PARAMETER RsaPaddingScheme + RSA padding scheme to use, default is `Pkcs1V15`. + +.OUTPUTS + [string] - The JWT signature as a base64url-encoded string. + +.EXAMPLE + $token = "header.payload" + $key = [System.Text.Encoding]::UTF8.GetBytes("my-secret-key") + $signature = New-PodeJwtSignature -Algorithm "HS256" -Token $token -SecretBytes $key + + This example generates a JWT signature using the HMAC SHA-256 algorithm. + +.EXAMPLE + $privateKey = Get-Content "private_key.pem" -Raw + $signature = New-PodeJwtSignature -Algorithm RS256 -Token "header.payload" -X509Certificate $certificate + +.NOTES + This function is an internal Pode function and is subject to change. +#> +function New-PodeJwtSignature { + [CmdletBinding(DefaultParameterSetName = 'SecretBytes')] + [OutputType([string])] + param( + [Parameter(Mandatory = $true, ParameterSetName = 'SecretBytes')] + [Parameter(Mandatory = $true, ParameterSetName = 'SecretSecureString')] + [ValidateSet('HS256', 'HS384', 'HS512')] + [string] + $Algorithm, + + [Parameter(Mandatory = $true)] + [string] + $Token, + + [Parameter(Mandatory = $true, ParameterSetName = 'SecretBytes')] + [byte[]] + $SecretBytes, + + [Parameter(Mandatory = $true, ParameterSetName = 'SecretSecureString')] + [securestring] + $Secret, + + [Parameter( Mandatory = $true, ParameterSetName = 'X509Certificate')] + [System.Security.Cryptography.X509Certificates.X509Certificate2] + $X509Certificate, + + [Parameter(Mandatory = $false, ParameterSetName = 'X509Certificate')] + [ValidateSet('Pkcs1V15', 'Pss')] + [string] + $RsaPaddingScheme = 'Pkcs1V15', + + [Parameter(Mandatory = $true, ParameterSetName = 'AuthenticationMethod')] + [string] + $Authentication + ) + $alg = $Algorithm + switch ($PSCmdlet.ParameterSetName) { + 'SecretBytes' { + if ($null -eq $SecretBytes) { + throw ($PodeLocale.missingKeyForAlgorithmExceptionMessage -f 'secret', 'HMAC', $Algorithm) + } + break + } + 'SecretSecureString' { + if ($null -eq $Secret) { + throw ($PodeLocale.missingKeyForAlgorithmExceptionMessage -f 'secret', 'HMAC', $Algorithm) + } + # Convert Secret to bytes if provided + $secretBytes = Convert-PodeSecureStringToByteArray -SecureString $Secret + break + } + 'X509Certificate' { + if ($null -eq $X509Certificate) { + throw ($PodeLocale.missingKeyForAlgorithmExceptionMessage -f 'private', 'RSA/ECSDA', $Algorithm) + } + $alg = Get-PodeJwtSigningAlgorithm -X509Certificate $X509Certificate -RsaPaddingScheme $RsaPaddingScheme + + break + } + 'AuthenticationMethod' { + if ($PodeContext -and $PodeContext.Server.Authentications.Methods.ContainsKey($Authentication)) { + $method = $PodeContext.Server.Authentications.Methods[$Authentication].Scheme.Arguments + $alg = $method.Algorithm + if ($null -ne $method.X509Certificate) { + $X509Certificate = $method.X509Certificate + } + if ($null -ne $method.Secret) { + $secretBytes = Convert-PodeSecureStringToByteArray -SecureString $method.Secret + } + } + else { + throw ($PodeLocale.authenticationMethodDoesNotExistExceptionMessage) + } + } + } + + $valueBytes = [System.Text.Encoding]::UTF8.GetBytes($Token) + + switch ($alg) { + + # HMAC-SHA (HS256, HS384, HS512) + { $_ -match '^HS(\d{3})$' } { + + # Map HS256, HS384, HS512 to their respective classes + $hmac = switch ($alg) { + 'HS256' { [System.Security.Cryptography.HMACSHA256]::new($SecretBytes); break } + 'HS384' { [System.Security.Cryptography.HMACSHA384]::new($SecretBytes); break } + 'HS512' { [System.Security.Cryptography.HMACSHA512]::new($SecretBytes); break } + default { throw ($PodeLocale.unsupportedJwtAlgorithmExceptionMessage -f $alg) } + } + + $signature = $hmac.ComputeHash($valueBytes) + break + } + + # RSA (RS256, RS384, RS512, PS256, PS384, PS512) + { $_ -match '^(RS|PS)(\d{3})$' } { + $rsa = [System.Security.Cryptography.X509Certificates.RSACertificateExtensions]::GetRSAPrivateKey($X509Certificate) + + # Map RS256, RS384, RS512 to their correct SHA algorithm + $hashAlgo = switch ($alg) { + 'RS256' { [System.Security.Cryptography.HashAlgorithmName]::SHA256; break } + 'RS384' { [System.Security.Cryptography.HashAlgorithmName]::SHA384; break } + 'RS512' { [System.Security.Cryptography.HashAlgorithmName]::SHA512; break } + 'PS256' { [System.Security.Cryptography.HashAlgorithmName]::SHA256; break } + 'PS384' { [System.Security.Cryptography.HashAlgorithmName]::SHA384; break } + 'PS512' { [System.Security.Cryptography.HashAlgorithmName]::SHA512; break } + default { throw ($PodeLocale.unsupportedJwtAlgorithmExceptionMessage -f $alg) } + } + + $rsaPadding = if ($alg -match '^PS') { + [System.Security.Cryptography.RSASignaturePadding]::Pss + } + else { + [System.Security.Cryptography.RSASignaturePadding]::Pkcs1 + } + + $signature = $rsa.SignData($valueBytes, $hashAlgo, $rsaPadding) + + break + } + + # ECDSA (ES256, ES384, ES512) + { $_ -match '^ES(\d{3})$' } { + $ecdsa = [System.Security.Cryptography.X509Certificates.ECDsaCertificateExtensions]::GetECDsaPrivateKey($X509Certificate) + + # Map ES256, ES384, ES512 to their correct SHA algorithm + $hashAlgo = switch ($alg) { + 'ES256' { [System.Security.Cryptography.HashAlgorithmName]::SHA256; break } + 'ES384' { [System.Security.Cryptography.HashAlgorithmName]::SHA384; break } + 'ES512' { [System.Security.Cryptography.HashAlgorithmName]::SHA512; break } + default { throw ($PodeLocale.unsupportedJwtAlgorithmExceptionMessage -f $alg) } + } + + $signature = $ecdsa.SignData($valueBytes, $hashAlgo) + break + } + + default { + throw ($PodeLocale.unsupportedJwtAlgorithmExceptionMessage -f $alg) + } + } + return [System.Convert]::ToBase64String($signature).Replace('+', '-').Replace('/', '_').TrimEnd('=') +} \ No newline at end of file diff --git a/src/Private/Middleware.ps1 b/src/Private/Middleware.ps1 index b15fc5760..2d644999a 100644 --- a/src/Private/Middleware.ps1 +++ b/src/Private/Middleware.ps1 @@ -314,6 +314,7 @@ function Get-PodeBodyMiddleware { # set session data $WebEvent.Data = $result.Data $WebEvent.Files = $result.Files + $WebEvent.RawData = $result.RawData # payload parsed return $true @@ -425,7 +426,7 @@ function Initialize-PodeIISMiddleware { try { $value = Get-PodeHeader -Name $header - $WebEvent.Request.ClientCertificate = [X509Certificates.X509Certificate2]::new([Convert]::FromBase64String($value)) + $WebEvent.Request.ClientCertificate = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new([Convert]::FromBase64String($value)) } catch { $WebEvent.Request.ClientCertificateErrors = [System.Net.Security.SslPolicyErrors]::RemoteCertificateNotAvailable diff --git a/src/Private/Security.ps1 b/src/Private/Security.ps1 index 0d3f4df0b..857404986 100644 --- a/src/Private/Security.ps1 +++ b/src/Private/Security.ps1 @@ -134,6 +134,62 @@ function Test-PodeCsrfConfigured { return (!(Test-PodeIsEmpty $PodeContext.Server.Cookies.Csrf)) } + +<# +.SYNOPSIS + Loads an X.509 certificate from a file (PFX, PEM, or CER), optionally decrypting it with a password. + +.DESCRIPTION + This function reads an X.509 certificate from a file and loads it as an X509Certificate2 object. + It supports: + - PFX (PKCS#12) certificates with optional password decryption. + - PEM certificates with a separate private key file. + - CER (DER or Base64-encoded) certificates (public key only). + + It applies the appropriate key storage flags depending on the operating system and + ensures compatibility with Pode’s certificate handling utilities. + +.PARAMETER Certificate + The file path to the certificate (.pfx, .pem, or .cer) to load. + +.PARAMETER SecurePassword + A secure string containing the password for decrypting the certificate (only applicable for PFX files). + +.PARAMETER PrivateKeyPath + The path to a separate private key file (only applicable for PEM certificates). + Required if the PEM certificate does not contain the private key. + +.PARAMETER Ephemeral + If specified, the certificate will be created with `EphemeralKeySet`, meaning the private key + will **not be persisted** on disk or in the certificate store. + + This is useful for temporary certificates that should only exist in memory for the duration + of the current session. Once the process exits, the private key will be lost. + +.PARAMETER Exportable + If specified the certificate will be created with `Exportable`, meaning the certificate can be exported + +.OUTPUTS + [System.Security.Cryptography.X509Certificates.X509Certificate2] + Returns an X.509 certificate object. + +.EXAMPLE + $cert = Get-PodeCertificateByFile -Certificate "C:\Certs\mycert.pfx" -SecurePassword (ConvertTo-SecureString -String "MyPass" -AsPlainText -Force) + Loads a PFX certificate with a password. + +.EXAMPLE + $cert = Get-PodeCertificateByFile -Certificate "C:\Certs\mycert.pem" -PrivateKeyPath "C:\Certs\mykey.pem" + Loads a PEM certificate with a separate private key. + +.EXAMPLE + $cert = Get-PodeCertificateByFile -Certificate "C:\Certs\mycert.cer" + Loads a CER certificate (public key only). + +.NOTES + - CER files do not contain private keys and cannot be decrypted with a password. + - PEM certificates may require a separate private key file. + - Uses EphemeralKeySet storage on non-macOS platforms for security. +#> function Get-PodeCertificateByFile { param( [Parameter(Mandatory = $true)] @@ -141,28 +197,49 @@ function Get-PodeCertificateByFile { $Certificate, [Parameter()] - [string] - $Password = $null, + [securestring] + $SecurePassword = $null, [Parameter()] [string] - $Key = $null + $PrivateKeyPath = $null, + + [Parameter()] + [switch] + $Ephemeral, + + [Parameter()] + [switch] + $Exportable ) + $path = Get-PodeRelativePath -Path $Certificate -JoinRoot -Resolve # cert + key - if (![string]::IsNullOrWhiteSpace($Key)) { - return (Get-PodeCertificateByPemFile -Certificate $Certificate -Password $Password -Key $Key) + if (![string]::IsNullOrWhiteSpace($PrivateKeyPath) -or ( [System.IO.Path]::GetExtension($path).ToLower() -eq '.pem') ) { + return (Get-PodeCertificateByPemFile -Certificate $Certificate -SecurePassword $SecurePassword -PrivateKeyPath $PrivateKeyPath) } - $path = Get-PodeRelativePath -Path $Certificate -JoinRoot -Resolve + # read the cert bytes from the file to avoid the use of obsolete constructors + $certBytes = [System.IO.File]::ReadAllBytes($path) - # cert + password - if (![string]::IsNullOrWhiteSpace($Password)) { - return [X509Certificates.X509Certificate2]::new($path, $Password) + if ($Ephemeral -and !$IsMacOS) { + $storageFlags = [System.Security.Cryptography.X509Certificates.X509KeyStorageFlags]::EphemeralKeySet + } + else { + $storageFlags = [System.Security.Cryptography.X509Certificates.X509KeyStorageFlags]::DefaultKeySet } + if ($Exportable) { + $storageFlags = $storageFlags -bor [System.Security.Cryptography.X509Certificates.X509KeyStorageFlags]::Exportable + } + + if ( [System.IO.Path]::GetExtension($path).ToLower() -eq '.pfx') { + if ($null -ne $SecurePassword) { + return [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($certBytes, $SecurePassword, $storageFlags) + } + } # plain cert - return [X509Certificates.X509Certificate2]::new($path) + return [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($certBytes, $null, $storageFlags) } function Get-PodeCertificateByPemFile { @@ -172,60 +249,103 @@ function Get-PodeCertificateByPemFile { $Certificate, [Parameter()] - [string] - $Password = $null, + [securestring] + $SecurePassword = $null, - [Parameter()] + [Parameter(Mandatory = $true)] [string] - $Key = $null + $PrivateKeyPath ) + if ($PSVersionTable.PSVersion.Major -lt 7) { + throw ($PodeLocale.pemCertificateNotSupportedByPwshVersionExceptionMessage -f $PSVersionTable.PSVersion) + } + $cert = $null $certPath = Get-PodeRelativePath -Path $Certificate -JoinRoot -Resolve - $keyPath = Get-PodeRelativePath -Path $Key -JoinRoot -Resolve + $keyPath = Get-PodeRelativePath -Path $PrivateKeyPath -JoinRoot -Resolve # pem's kinda work in .NET3/.NET5 if ([version]$PSVersionTable.PSVersion -ge [version]'7.0.0') { - $cert = [X509Certificates.X509Certificate2]::new($certPath) + $cert = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($certPath) $keyText = [System.IO.File]::ReadAllText($keyPath) - $rsa = [RSA]::Create() - - # .NET5 - if ([version]$PSVersionTable.PSVersion -ge [version]'7.1.0') { - if ([string]::IsNullOrWhiteSpace($Password)) { - $rsa.ImportFromPem($keyText) - } + try { + $rsa = [RSA]::Create() + + # .NET5 + if ([version]$PSVersionTable.PSVersion -ge [version]'7.1.0') { + if ($null -eq $SecurePassword ) { + $rsa.ImportFromPem($keyText) + } + else { + $rsa.ImportFromEncryptedPem($keyText, (Convert-PodeSecureStringToPlainText -SecureString $SecurePassword)) + } + } # .NET3 else { - $rsa.ImportFromEncryptedPem($keyText, $Password) + $keyBlocks = $keyText.Split('-', [System.StringSplitOptions]::RemoveEmptyEntries) + $keyBytes = [System.Convert]::FromBase64String($keyBlocks[1]) + + if ($keyBlocks[0] -ieq 'BEGIN PRIVATE KEY') { + $rsa.ImportPkcs8PrivateKey($keyBytes, [ref]$null) + } + elseif ($keyBlocks[0] -ieq 'BEGIN RSA PRIVATE KEY') { + $rsa.ImportRSAPrivateKey($keyBytes, [ref]$null) + } + elseif ($keyBlocks[0] -ieq 'BEGIN ENCRYPTED PRIVATE KEY') { + if ($null -ne $SecurePassword) { + [int32]$bytesRead = 0 + $rsa.ImportEncryptedPkcs8PrivateKey( (Convert-PodeSecureStringToPlainText -SecureString $SecurePassword), $keyBytes, [ref]$bytesRead) + } + } + $cert = [System.Security.Cryptography.X509Certificates.RSACertificateExtensions]::CopyWithPrivateKey($cert, $rsa) } } - - # .NET3 - else { - $keyBlocks = $keyText.Split('-', [System.StringSplitOptions]::RemoveEmptyEntries) - $keyBytes = [System.Convert]::FromBase64String($keyBlocks[1]) - - if ($keyBlocks[0] -ieq 'BEGIN PRIVATE KEY') { - $rsa.ImportPkcs8PrivateKey($keyBytes, [ref]$null) - } - elseif ($keyBlocks[0] -ieq 'BEGIN RSA PRIVATE KEY') { - $rsa.ImportRSAPrivateKey($keyBytes, [ref]$null) - } - elseif ($keyBlocks[0] -ieq 'BEGIN ENCRYPTED PRIVATE KEY') { - $rsa.ImportEncryptedPkcs8PrivateKey($Password, $keyBytes, [ref]$null) + catch { + $ecsd = [ECDSA]::Create() + if ([version]$PSVersionTable.PSVersion -ge [version]'7.1.0') { + if ( $null -eq $SecurePassword ) { + $ecsd.ImportFromPem($keyText) + } + else { + $ecsd.ImportFromEncryptedPem($keyText, (Convert-PodeSecureStringToByteArray -SecureString $SecurePassword)) + + } + + + # .NET3 + else { + $keyBlocks = $keyText.Split('-', [System.StringSplitOptions]::RemoveEmptyEntries) + $keyBytes = [System.Convert]::FromBase64String($keyBlocks[1]) + + if ($keyBlocks[0] -ieq 'BEGIN PRIVATE KEY') { + $ecsd.ImportPkcs8PrivateKey($keyBytes, [ref]$null) + } + elseif ($keyBlocks[0] -ieq 'BEGIN RSA PRIVATE KEY') { + $ecsd.ImportRSAPrivateKey($keyBytes, [ref]$null) + } + elseif ($keyBlocks[0] -ieq 'BEGIN ENCRYPTED PRIVATE KEY') { + if ($null -ne $SecurePassword) { + [int32]$bytesRead = 0 + $ecsd.ImportEncryptedPkcs8PrivateKey( (Convert-PodeSecureStringToByteArray -SecureString $SecurePassword), $keyBytes, [ref]$bytesRead) + } + } + } + + + $cert = [System.Security.Cryptography.X509Certificates.ECDsaCertificateExtensions]::CopyWithPrivateKey($cert, $ecsd) } + $cert = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($cert.Export([System.Security.Cryptography.X509Certificates.X509ContentType]::Pkcs12)) } - - $cert = [X509Certificates.RSACertificateExtensions]::CopyWithPrivateKey($cert, $rsa) - $cert = [X509Certificates.X509Certificate2]::new($cert.Export([X509Certificates.X509ContentType]::Pkcs12)) } - # for everything else, there's the openssl way else { $tempFile = Join-Path (Split-Path -Parent -Path $certPath) 'temp.pfx' try { + if ($null -ne $SecurePassword) { + $Password = Convert-PodeSecureStringToPlainText -SecureString $SecurePassword + } if ([string]::IsNullOrWhiteSpace($Password)) { $Password = [string]::Empty } @@ -235,7 +355,7 @@ function Get-PodeCertificateByPemFile { throw ($PodeLocale.failedToCreateOpenSslCertExceptionMessage -f $result) #"Failed to create openssl cert: $($result)" } - $cert = [X509Certificates.X509Certificate2]::new($tempFile, $Password) + $cert = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($tempFile, $Password) } finally { $null = Remove-Item $tempFile -Force @@ -248,7 +368,7 @@ function Get-PodeCertificateByPemFile { function Find-PodeCertificateInCertStore { param( [Parameter(Mandatory = $true)] - [X509Certificates.X509FindType] + [System.Security.Cryptography.X509Certificates.X509FindType] $FindType, [Parameter(Mandatory = $true)] @@ -256,11 +376,11 @@ function Find-PodeCertificateInCertStore { $Query, [Parameter(Mandatory = $true)] - [X509Certificates.StoreName] + [System.Security.Cryptography.X509Certificates.StoreName] $StoreName, [Parameter(Mandatory = $true)] - [X509Certificates.StoreLocation] + [System.Security.Cryptography.X509Certificates.StoreLocation] $StoreLocation ) @@ -271,11 +391,11 @@ function Find-PodeCertificateInCertStore { } # open the currentuser\my store - $x509store = [X509Certificates.X509Store]::new($StoreName, $StoreLocation) + $x509store = [System.Security.Cryptography.X509Certificates.X509Store]::new($StoreName, $StoreLocation) try { # attempt to find the cert - $x509store.Open([X509Certificates.OpenFlags]::ReadOnly) + $x509store.Open([System.Security.Cryptography.X509Certificates.OpenFlags]::ReadOnly) $x509certs = $x509store.Certificates.Find($FindType, $Query, $false) } finally { @@ -290,7 +410,7 @@ function Find-PodeCertificateInCertStore { throw ($PodeLocale.noCertificateFoundExceptionMessage -f $StoreLocation, $StoreName, $Query) # "No certificate could be found in $($StoreLocation)\$($StoreName) for '$($Query)'" } - return ([X509Certificates.X509Certificate2]($x509certs[0])) + return ([System.Security.Cryptography.X509Certificates.X509Certificate2]($x509certs[0])) } function Get-PodeCertificateByThumbprint { @@ -300,16 +420,16 @@ function Get-PodeCertificateByThumbprint { $Thumbprint, [Parameter(Mandatory = $true)] - [X509Certificates.StoreName] + [System.Security.Cryptography.X509Certificates.StoreName] $StoreName, [Parameter(Mandatory = $true)] - [X509Certificates.StoreLocation] + [System.Security.Cryptography.X509Certificates.StoreLocation] $StoreLocation ) return Find-PodeCertificateInCertStore ` - -FindType ([X509Certificates.X509FindType]::FindByThumbprint) ` + -FindType ([System.Security.Cryptography.X509Certificates.X509FindType]::FindByThumbprint) ` -Query $Thumbprint ` -StoreName $StoreName ` -StoreLocation $StoreLocation @@ -322,23 +442,23 @@ function Get-PodeCertificateByName { $Name, [Parameter(Mandatory = $true)] - [X509Certificates.StoreName] + [System.Security.Cryptography.X509Certificates.StoreName] $StoreName, [Parameter(Mandatory = $true)] - [X509Certificates.StoreLocation] + [System.Security.Cryptography.X509Certificates.StoreLocation] $StoreLocation ) return Find-PodeCertificateInCertStore ` - -FindType ([X509Certificates.X509FindType]::FindBySubjectName) ` + -FindType ([System.Security.Cryptography.X509Certificates.X509FindType]::FindBySubjectName) ` -Query $Name ` -StoreName $StoreName ` -StoreLocation $StoreLocation } -function New-PodeSelfSignedCertificate { - $sanBuilder = [X509Certificates.SubjectAlternativeNameBuilder]::new() +function New-PodeSelfSignedCertificate2 { + $sanBuilder = [System.Security.Cryptography.X509Certificates.SubjectAlternativeNameBuilder]::new() $null = $sanBuilder.AddIpAddress([ipaddress]::Loopback) $null = $sanBuilder.AddIpAddress([ipaddress]::IPv6Loopback) $null = $sanBuilder.AddDnsName('localhost') @@ -350,7 +470,7 @@ function New-PodeSelfSignedCertificate { $rsa = [RSA]::Create(2048) $distinguishedName = [X500DistinguishedName]::new('CN=localhost') - $req = [X509Certificates.CertificateRequest]::new( + $req = [System.Security.Cryptography.X509Certificates.CertificateRequest]::new( $distinguishedName, $rsa, [HashAlgorithmName]::SHA256, @@ -358,13 +478,13 @@ function New-PodeSelfSignedCertificate { ) $flags = ( - [X509Certificates.X509KeyUsageFlags]::DataEncipherment -bor - [X509Certificates.X509KeyUsageFlags]::KeyEncipherment -bor - [X509Certificates.X509KeyUsageFlags]::DigitalSignature + [System.Security.Cryptography.X509Certificates.X509KeyUsageFlags]::DataEncipherment -bor + [System.Security.Cryptography.X509Certificates.X509KeyUsageFlags]::KeyEncipherment -bor + [System.Security.Cryptography.X509Certificates.X509KeyUsageFlags]::DigitalSignature ) $null = $req.CertificateExtensions.Add( - [X509Certificates.X509KeyUsageExtension]::new( + [System.Security.Cryptography.X509Certificates.X509KeyUsageExtension]::new( $flags, $false ) @@ -374,7 +494,7 @@ function New-PodeSelfSignedCertificate { $null = $oid.Add([Oid]::new('1.3.6.1.5.5.7.3.1')) $req.CertificateExtensions.Add( - [X509Certificates.X509EnhancedKeyUsageExtension]::new( + [System.Security.Cryptography.X509Certificates.X509EnhancedKeyUsageExtension]::new( $oid, $false ) @@ -391,8 +511,8 @@ function New-PodeSelfSignedCertificate { $cert.FriendlyName = 'localhost' } - $cert = [X509Certificates.X509Certificate2]::new( - $cert.Export([X509Certificates.X509ContentType]::Pfx, 'self-signed'), + $cert = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new( + $cert.Export([System.Security.Cryptography.X509Certificates.X509ContentType]::Pfx, 'self-signed'), 'self-signed' ) diff --git a/src/Public/Authentication.ps1 b/src/Public/Authentication.ps1 index 4b729f0e6..b8c0ee9c4 100644 --- a/src/Public/Authentication.ps1 +++ b/src/Public/Authentication.ps1 @@ -368,54 +368,43 @@ function New-PodeAuthScheme { } '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') - } + # Display a deprecation warning for the old function. + # This ensures users are informed that the function is obsolete and should transition to the new function. + Write-PodeDeprecationWarning -OldFunction 'New-PodeAuthScheme -Digest' -NewFunction 'New-PodeAuthDigestScheme' + + $params = @{ + HeaderTag = $HeaderTag + Scope = $Scope } + return New-PodeAuthDigestScheme $params } 'bearer' { - $secretBytes = $null - if (![string]::IsNullOrWhiteSpace($Secret)) { - $secretBytes = [System.Text.Encoding]::UTF8.GetBytes($Secret) + # Display a deprecation warning for the old function. + # This ensures users are informed that the function is obsolete and should transition to the new function. + Write-PodeDeprecationWarning -OldFunction 'New-PodeAuthScheme -Bearer' -NewFunction 'New-PodeAuthBearerScheme' + + $params = @{ + BearerTag = $HeaderTag + Scope = $Scope + AsJWT = $AsJWT } - return @{ - Name = 'Bearer' - Realm = (Protect-PodeValue -Value $Realm -Default $_realm) - ScriptBlock = @{ - Script = (Get-PodeAuthBearerType) - UsingVariables = $null - } - PostValidator = @{ - Script = (Get-PodeAuthBearerPostValidator) - UsingVariables = $null + if ($Secret) { + if ($Secret -isnot [SecureString]) { + if ( $Secret -is [string]) { + # Convert plain string to SecureString + $params['Secret'] = ConvertTo-SecureString -String $Secret -AsPlainText -Force + } + else { + throw + } } - Middleware = $Middleware - Scheme = 'http' - InnerScheme = $InnerScheme - Arguments = @{ - Description = $Description - HeaderTag = (Protect-PodeValue -Value $HeaderTag -Default 'Bearer') - Scopes = $Scope - AsJWT = $AsJWT - Secret = $secretBytes + else { + $params['Secret'] = $Secret } } + return New-PodeAuthBearerScheme @params } 'form' { @@ -508,9 +497,13 @@ function New-PodeAuthScheme { })[$Location] } - $secretBytes = $null - if (![string]::IsNullOrWhiteSpace($Secret)) { - $secretBytes = [System.Text.Encoding]::UTF8.GetBytes($Secret) + if (! ([string]::IsNullOrEmpty($Secret))) { + $SecretString = ConvertTo-SecureString -String $Secret -AsPlainText -Force + $alg = @( 'HS256', 'HS384', 'HS512' ) + } + else { + $SecretString = $null + $alg = 'NONE' } return @{ @@ -529,7 +522,8 @@ function New-PodeAuthScheme { Location = $Location LocationName = $LocationName AsJWT = $AsJWT - Secret = $secretBytes + Secret = $SecretString + Algorithm = $alg } } } @@ -2210,217 +2204,7 @@ function Add-PodeAuthWindowsLocal { } } -<# -.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 @@ -2734,4 +2518,340 @@ function New-PodeAuthKeyTab { ) ktpass /princ HTTP/$Hostname@$DomainName /mapuser $Username /pass $Password /out $FilePath /crypto $Crypto /ptype KRB5_NT_PRINCIPAL /mapop set +} + +<# +.SYNOPSIS + Creates a new Bearer authentication scheme for Pode. + +.DESCRIPTION + Defines a Bearer authentication scheme that allows authentication using a raw Bearer token or JWT. + Supports JWT validation with configurable security levels and token extraction from headers or query parameters. + +.PARAMETER BearerTag + The header tag used for the Bearer token (default: "Bearer"). + +.PARAMETER Location + Specifies the token extraction location: `Header` (default) or `Query`. + +.PARAMETER Scope + A list of required scopes for the authentication scheme. + +.PARAMETER Algorithm + Accepted JWT signing algorithms: HS256, HS384, HS512. + +.PARAMETER AsJWT + Indicates if the Bearer token should be treated and validated as a JWT. + +.PARAMETER Secret + The HMAC secret key for JWT validation (required for HS256, HS384, HS512). + +.PARAMETER Certificate + The path to a certificate that can be use to enable HTTPS + +.PARAMETER Certificate + The path to a certificate used for RSA or ECDSA verification. + +.PARAMETER CertificatePassword + The password for the certificate file referenced in Certificate + +.PARAMETER PrivateKeyPath + A key file to be paired with a PEM certificate file referenced in Certificate + +.PARAMETER CertificateThumbprint + A certificate thumbprint to use for RSA or ECDSA verification. (Windows). + +.PARAMETER CertificateName + A certificate subject name to use for RSA or ECDSA verification. (Windows). + +.PARAMETER CertificateStoreName + The name of a certifcate store where a certificate can be found (Default: My) (Windows). + +.PARAMETER CertificateStoreLocation + The location of a certifcate store where a certificate can be found (Default: CurrentUser) (Windows). + +.PARAMETER SelfSigned + Create and bind a self-signed CodeSigning ECSDA 384 Certificate. + +.PARAMETER X509Certificate + The raw X509 certificate used for RSA or ECDSA verification. + +.PARAMETER RsaPaddingScheme + RSA padding scheme: `Pkcs1V15` (default) or `Pss`. + +.PARAMETER JwtVerificationMode + JWT validation strictness: `Strict`, `Moderate`, or `Lenient` (default). + +.OUTPUTS + [hashtable] - Returns the Bearer authentication scheme configuration. + +.EXAMPLE + New-PodeAuthBearerScheme -AsJWT -Algorithm "HS256" -Secret (ConvertTo-SecureString "MySecretKey" -AsPlainText -Force) + +.EXAMPLE + New-PodeAuthBearerScheme -AsJWT -Algorithm "RS256" -PrivateKey (Get-Content "private.pem" -Raw) -PublicKey (Get-Content "public.pem" -Raw) +#> +function New-PodeAuthBearerScheme { + [CmdletBinding(DefaultParameterSetName = 'Basic')] + [OutputType([hashtable])] + param( + [string] + $BearerTag, + + [ValidateSet('Header', 'Query', 'Body')] + [string] + $Location = 'Header', + + [string[]] + $Scope, + + [switch] + $AsJWT, + + [Parameter(Mandatory = $false, ParameterSetName = 'Bearer_HS')] + [ValidateSet('HS256', 'HS384', 'HS512')] + [string[]] + $Algorithm = @(), + + [Parameter(Mandatory = $true, ParameterSetName = 'Bearer_HS')] + [SecureString] + $Secret, + + # Certificate-based parameters for RSA/ECDSA + [Parameter(Mandatory = $true, ParameterSetName = 'CertFile')] + [string] + $Certificate, + + [Parameter(Mandatory = $false, ParameterSetName = 'CertFile')] + [string] + $PrivateKeyPath = $null, + + [Parameter(Mandatory = $false, ParameterSetName = 'CertFile')] + [SecureString] + $CertificatePassword, + + [Parameter(Mandatory = $true, ParameterSetName = 'CertThumb')] + [string] + $CertificateThumbprint, + + [Parameter(Mandatory = $true, ParameterSetName = 'CertName')] + [string] + $CertificateName, + + [Parameter(ParameterSetName = 'CertName')] + [Parameter(ParameterSetName = 'CertThumb')] + [System.Security.Cryptography.X509Certificates.StoreName] + $CertificateStoreName = 'My', + + [Parameter(ParameterSetName = 'CertName')] + [Parameter(ParameterSetName = 'CertThumb')] + [System.Security.Cryptography.X509Certificates.StoreLocation] + $CertificateStoreLocation = 'CurrentUser', + + [Parameter(Mandatory = $true, ParameterSetName = 'CertRaw')] + [X509Certificate] + $X509Certificate = $null, + + [Parameter(Mandatory = $true, ParameterSetName = 'CertSelf')] + [switch] + $SelfSigned, + + [Parameter(Mandatory = $false, ParameterSetName = 'CertRaw')] + [Parameter(Mandatory = $false, ParameterSetName = 'CertFile')] + [Parameter(Mandatory = $false, ParameterSetName = 'CertName')] + [Parameter(Mandatory = $false, ParameterSetName = 'CertThumb')] + [Parameter(Mandatory = $false, ParameterSetName = 'CertSelf')] + [ValidateSet('Pkcs1V15', 'Pss')] + [string] + $RsaPaddingScheme = 'Pkcs1V15', + + # Mode for verifying the JWT claims if AsJWT is used + [Parameter(Mandatory = $false, ParameterSetName = 'Bearer_HS')] + [Parameter(Mandatory = $false, ParameterSetName = 'CertName')] + [Parameter(Mandatory = $false, ParameterSetName = 'CertThumb')] + [Parameter(Mandatory = $false, ParameterSetName = 'CertRaw')] + [Parameter(Mandatory = $false, ParameterSetName = 'CertFile')] + [Parameter(Mandatory = $false, ParameterSetName = 'CertSelf')] + [ValidateSet('Strict', 'Moderate', 'Lenient')] + [string] + $JwtVerificationMode = 'Lenient' + ) + + # The default authentication realm + $_realm = 'User' + + # Convert any middleware to valid hashtables, if used in Pode + # (Assumes ConvertTo-PodeMiddleware is a function available in your codebase) + $Middleware = @(ConvertTo-PodeMiddleware -Middleware $Middleware -PSSession $PSCmdlet.SessionState) + + # Determine parameter set behavior for different JWT signing methods + switch ($PSCmdlet.ParameterSetName) { + 'CertFile' { + # If using a file-based certificate, ensure it exists, then load it + if (!(Test-Path -Path $Certificate -PathType Leaf)) { + throw ($PodeLocale.pathNotExistExceptionMessage -f $Certificate) + } + $X509Certificate = Get-PodeCertificateByFile -Certificate $Certificate -SecurePassword $CertificatePassword -PrivateKeyPath $PrivateKeyPath + break + } + + 'certthumb' { + # Retrieve a certificate from the local store by thumbprint + $X509Certificate = Get-PodeCertificateByThumbprint -Thumbprint $CertificateThumbprint -StoreName $CertificateStoreName -StoreLocation $CertificateStoreLocation + } + + 'certname' { + # Retrieve a certificate from the local store by name + $X509Certificate = Get-PodeCertificateByName -Name $CertificateName -StoreName $CertificateStoreName -StoreLocation $CertificateStoreLocation + } + + 'Bearer_HS' { + # If no algorithm is provided for HMAC, default to HS256 + $alg = if ($Algorithm.Count -eq 0) { + @('HS256') + } + else { + $Algorithm + } + break + } + + 'CertSelf' { + $X509Certificate = New-PodeSelfSignedCertificate -CommonName 'JWT Signing Certificate' ` + -KeyType ECDSA -KeyLength 384 -CertificatePurpose CodeSigning -Ephemeral + break + } + + 'Bearer_NONE' { + # Use the 'NONE' algorithm, meaning no signature check + $alg = @('NONE') + break + } + } + + # If an X509 certificate is being used, detect the signing algorithm + if ($null -ne $X509Certificate) { + + # Skip certificate validation if it has been explicitly provided as a variable. + if ($PSCmdlet.ParameterSetName -ne 'CertRaw') { + # Validate that the certificate: + # 1. Is within its validity period. + # 2. Has a valid certificate chain. + # 3. Is explicitly authorized for the expected purpose (Code Signing). + # 4. Meets strict Enhanced Key Usage (EKU) enforcement. + $null = Test-PodeCertificate -Certificate $X509Certificate -ExpectedPurpose CodeSigning -Strict -ErrorAction Stop + } + + # Retrieve appropriate JWT algorithms (e.g., RS256, ES256) from the provided certificate + $alg = @( Get-PodeJwtSigningAlgorithm -X509Certificate $X509Certificate -RsaPaddingScheme $RsaPaddingScheme ) + } + + # Return the Bearer authentication scheme configuration as a hashtable + # This hashtable is how Pode recognizes and initializes the scheme + 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 + BearerTag = (Protect-PodeValue -Value $BearerTag -Default 'Bearer') + Scopes = $Scope + AsJWT = $AsJWT + Secret = $Secret + Location = $Location + JwtVerificationMode = $JwtVerificationMode + Algorithm = $alg + X509Certificate = $X509Certificate + } + } +} + +<# +.SYNOPSIS + Creates a new Digest authentication scheme for Pode. + +.DESCRIPTION + This function defines a Digest authentication scheme in Pode. It allows specifying + parameters such as the authentication algorithm, quality of protection, and an optional + header tag. The function ensures secure authentication by leveraging Pode’s built-in + digest authentication mechanisms. + +.PARAMETER HeaderTag + An optional custom header tag for the authentication scheme. Defaults to 'Digest'. + +.PARAMETER Algorithm + Specifies the digest algorithm used for authentication. The default is 'MD5'. + Other supported values include 'SHA-1', 'SHA-256', 'SHA-512', 'SHA-384', and 'SHA-512/256'. + +.PARAMETER QualityOfProtection + Determines the Quality of Protection (QoP) setting for authentication. The default is 'auth'. + Available options are 'auth', 'auth-int', and 'auth,auth-int'. + +.OUTPUTS + Hashtable containing the defined Digest authentication scheme for Pode. + +.EXAMPLE + New-PodeAuthDigestScheme -Algorithm 'SHA-256' -QualityOfProtection 'auth-int' + + This example creates a new Digest authentication scheme using SHA-256 and sets + the Quality of Protection to 'auth-int'. + +.NOTES + Internal function for Pode authentication schemes. Subject to change in future updates. +#> +function New-PodeAuthDigestScheme { + [CmdletBinding(DefaultParameterSetName = 'Basic')] + [OutputType([hashtable])] + param( + + [Parameter(ParameterSetName = 'Digest')] + [string] + $HeaderTag, + + [Parameter(ParameterSetName = 'Digest')] + [ValidateSet('MD5', 'SHA-1', 'SHA-256', 'SHA-512', 'SHA-384', 'SHA-512/256')] + [string[]] + $Algorithm = 'MD5', + + [Parameter(ParameterSetName = 'Digest')] + [ValidateSet('auth', 'auth-int', 'auth,auth-int' )] + [string[]] + $QualityOfProtection = 'auth' + ) + # default realm + $_realm = 'User' + + # convert any middleware into valid hashtables + $Middleware = @(ConvertTo-PodeMiddleware -Middleware $Middleware -PSSession $PSCmdlet.SessionState) + + 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') + Algorithm = $Algorithm + QualityOfProtection = $QualityOfProtection + } + } } \ No newline at end of file diff --git a/src/Public/Certificate.ps1 b/src/Public/Certificate.ps1 new file mode 100644 index 000000000..0a7964fb4 --- /dev/null +++ b/src/Public/Certificate.ps1 @@ -0,0 +1,1052 @@ +<# +.SYNOPSIS + Generates a certificate signing request (CSR) and private key. + +.DESCRIPTION + This function creates a certificate signing request (CSR) using RSA or ECDSA key pairs. + It allows specifying subject details, key usage, enhanced key usage (EKU), and custom extensions. + The CSR and private key are automatically saved to files in the specified output directory. + +.PARAMETER DnsName + One or more DNS names (or IP addresses) to be included in the Subject Alternative Name (SAN). + +.PARAMETER CommonName + The Common Name (CN) for the certificate subject. Defaults to the first DNS name if not provided. + +.PARAMETER Organization + The organization (O) name to be included in the certificate subject. + +.PARAMETER Locality + The locality (L) name to be included in the certificate subject. + +.PARAMETER State + The state (S) name to be included in the certificate subject. + +.PARAMETER Country + The country (C) code (ISO 3166-1 alpha-2). Defaults to 'XX'. + +.PARAMETER KeyType + The cryptographic key type for the certificate request. Supported values: 'RSA', 'ECDSA'. Defaults to 'RSA'. + +.PARAMETER KeyLength + The key length for RSA (2048, 3072, 4096) or ECDSA (256, 384, 521). Defaults to 2048. + +.PARAMETER CertificatePurpose + The intended purpose of the certificate, which automatically sets the EKU. + Supported values: 'ServerAuth', 'ClientAuth', 'CodeSigning', 'EmailSecurity', 'Custom'. + +.PARAMETER EnhancedKeyUsages + A list of OID strings for Enhanced Key Usage (EKU) if 'Custom' is selected as CertificatePurpose. + +.PARAMETER NotBefore + The NotBefore date for the certificate request. Defaults to the current UTC time. + +.PARAMETER CustomExtensions + An array of additional custom certificate extensions. + +.PARAMETER FriendlyName + An optional friendly name for the certificate request. + +.PARAMETER OutputPath + The directory where the CSR and private key files will be saved. Defaults to the current working directory. + +.OUTPUTS + [PSCustomObject] + Returns an object containing: + - CsrPath: The file path of the CSR. + - PrivateKeyPath: The file path of the private key. + +.EXAMPLE + $csr = New-PodeCertificateRequest -DnsName "example.com" -CommonName "example.com" -KeyType "RSA" -KeyLength 2048 + Generates an RSA CSR for "example.com" and saves it to the current working directory. + +.EXAMPLE + $csr = New-PodeCertificateRequest -DnsName "example.com" -KeyType "ECDSA" -KeyLength 384 -CertificatePurpose "ServerAuth" -OutputPath "C:\Certs" + Generates an ECDSA CSR with an automatically assigned EKU for server authentication and saves it to "C:\Certs". + +.NOTES + - This function integrates with Pode’s certificate handling utilities. + - The private key is exported in PKCS#8 format. + - Ensure the private key is stored securely. +#> +function New-PodeCertificateRequest { + [CmdletBinding(DefaultParameterSetName = 'CommonName')] + [OutputType([PSCustomObject])] + param ( + # Required: one or more DNS names (or IP addresses) + [Parameter()] + [string[]] + $DnsName, + + # Subject parts + [Parameter()] + [string] + $CommonName, + + [Parameter()] + [string] + $Organization, + + [Parameter()] + [string] + $Locality, + + [Parameter()] + [string] + $State, + + [Parameter()] + [string] + $Country = 'XX', + + # Key type and size + [Parameter()] + [ValidateSet('RSA', 'ECDSA')] + [string]$KeyType = 'RSA', + + [Parameter()] + [ValidateSet(2048, 3072, 4096, 256, 384, 521)] + [int]$KeyLength = 2048, + + #Automatically set EKUs based on intended purpose + [Parameter()] + [ValidateSet('ServerAuth', 'ClientAuth', 'CodeSigning', 'EmailSecurity', 'Custom')] + [string] + $CertificatePurpose, + + # Enhanced Key Usages (EKU) - supply one or more OID strings if desired. + [Parameter()] + [string[]]$EnhancedKeyUsages, + + # Optional NotBefore date for the certificate request. + [Parameter()] + [DateTime]$NotBefore = ([datetime]::UtcNow), + + # Additional custom extensions (as an array of certificate extension objects). + [Parameter()] + [object[]]$CustomExtensions, + + # Optional friendly name for display in certificate stores. + [Parameter()] + [string]$FriendlyName, + + [Parameter()] + [string]$OutputPath = $PWD + ) + # Call the certificate request function to generate the CSR and key pair. + $csrParams = @{ + DnsName = $DnsName + CommonName = $CommonName + Organization = $Organization + Locality = $Locality + State = $State + Country = $Country + KeyType = $KeyType + KeyLength = $KeyLength + EnhancedKeyUsages = $EnhancedKeyUsages + NotBefore = $NotBefore + CustomExtensions = $CustomExtensions + FriendlyName = $FriendlyName + } + + # Define the encoding based on the powershell edition + $encoding = if ($PSVersionTable.PSEdition -eq 'Core') { + 'utf8NoBOM' + } + else { + 'utf8' + } + + $csrObject = New-PodeCertificateRequestInternal @csrParams + + + $csrPath = Join-Path -Path $OutputPath -ChildPath "$CommonName.csr" + $keyPath = Join-Path -Path $OutputPath -ChildPath "$CommonName.key" + + $csrObject.Request | Out-File -FilePath $csrPath -Encoding $encoding + $privateKeyBytes = $csrObject.PrivateKey.ExportPkcs8PrivateKey() + $privateKeyBase64 = [Convert]::ToBase64String($privateKeyBytes) + + "-----BEGIN PRIVATE KEY-----`n$privateKeyBase64`n-----END PRIVATE KEY-----" | Out-File -FilePath $keyPath -Encoding $encoding + + Write-Verbose "CSR saved to: $csrPath" + Write-Verbose "Private Key saved to: $keyPath" + + return [PSCustomObject]@{ + PsTypeName = 'PodeCertificateRequestResult' + CsrPath = $csrPath + PrivateKeyPath = $keyPath + } + +} + +<# +.SYNOPSIS + Generates a self-signed X.509 certificate. + +.DESCRIPTION + This function creates a self-signed X.509 certificate using RSA or ECDSA key pairs. + It supports specifying subject details, key usage, enhanced key usage (EKU), + and custom extensions. The generated certificate is returned as an X509Certificate2 object. + + By default, the private key is exportable so the certificate can be saved and reused. + If the `-Ephemeral` parameter is specified, the certificate's private key **will not be persisted** + and will only exist in memory for the current session. + +.PARAMETER DnsName + One or more DNS names (or IP addresses) to be included in the Subject Alternative Name (SAN). + +.PARAMETER Loopback + If specified, automatically sets `DnsName` to include: + - `127.0.0.1`, `::1`, `localhost` + - The current machine's IP (if not local) + - The Pode server's hostname and FQDN (if available) + +.PARAMETER CommonName + The Common Name (CN) for the certificate subject. Defaults to "SelfSigned". + +.PARAMETER Organization + The organization (O) name to be included in the certificate subject. + +.PARAMETER Locality + The locality (L) name to be included in the certificate subject. + +.PARAMETER State + The state (S) name to be included in the certificate subject. + +.PARAMETER Country + The country (C) code (ISO 3166-1 alpha-2). Defaults to 'XX'. + +.PARAMETER KeyType + The cryptographic key type for the certificate request. Supported values: 'RSA', 'ECDSA'. Defaults to 'RSA'. + +.PARAMETER KeyLength + The key length for RSA (2048, 3072, 4096) or ECDSA (256, 384, 521). Defaults to 2048. + +.PARAMETER EnhancedKeyUsages + A list of OID strings for Enhanced Key Usage (EKU), e.g., '1.3.6.1.5.5.7.3.1' for server authentication. + +.PARAMETER CertificatePurpose + The intended purpose of the certificate, which automatically sets the EKU. + Supported values: 'ServerAuth', 'ClientAuth', 'CodeSigning', 'EmailSecurity', 'Custom'. + Defaults to 'ServerAuth'. + +.PARAMETER NotBefore + The NotBefore date for the certificate validity start. Defaults to the current UTC time. + +.PARAMETER CustomExtensions + An array of additional custom certificate extensions. + +.PARAMETER FriendlyName + A friendly name for the certificate, used when storing it in a certificate store. Defaults to 'MyCertificate'. + +.PARAMETER ValidityDays + The number of days the certificate will remain valid. Defaults to 365 days. + +.PARAMETER Ephemeral + If specified, the certificate will be created with `EphemeralKeySet`, meaning the private key + will **not be persisted** on disk or in the certificate store. + + This is useful for temporary certificates that should only exist in memory for the duration + of the current session. Once the process exits, the private key will be lost. + +.PARAMETER Password + Specifies an optional password for protecting the exported PFX. If not provided, the PFX will be unprotected. + +.PARAMETER Exportable + If specified the certificate will be created with `Exportable`, meaning the certificate can be exported + +.OUTPUTS + [System.Security.Cryptography.X509Certificates.X509Certificate2] + Returns the generated self-signed certificate as an X509Certificate2 object. + +.EXAMPLE + $cert = New-PodeSelfSignedCertificate -Loopback + Creates a self-signed certificate for local addresses (`127.0.0.1`, `::1`, `localhost`, machine hostname). + +.EXAMPLE + $cert = New-PodeSelfSignedCertificate -DnsName "example.com" -KeyType "RSA" -KeyLength 2048 + Creates a self-signed RSA certificate for "example.com" with a 2048-bit key, valid for 365 days. + +.EXAMPLE + $cert = New-PodeSelfSignedCertificate -DnsName "internal.local" -Ephemeral + Creates a self-signed certificate with a private key that exists **only in memory** for the current session. + +.EXAMPLE + $cert = New-PodeSelfSignedCertificate -DnsName "testserver.local" -KeyType "ECDSA" -KeyLength 384 -CertificatePurpose "ClientAuth" -ValidityDays 730 + Generates a self-signed ECDSA certificate for "testserver.local" with client authentication EKU, valid for 730 days. + +.NOTES + - The private key is embedded in the generated certificate. + - By default, the certificate is **exportable** so it can be saved and reused. + - If `-Ephemeral` is used, the private key will **only exist in memory** and cannot be exported or stored. + - The `-Loopback` parameter is useful for local development, ensuring the certificate includes local identifiers. +#> +function New-PodeSelfSignedCertificate { + [CmdletBinding(DefaultParameterSetName = 'CommonName')] + [OutputType([System.Security.Cryptography.X509Certificates.X509Certificate2])] + param ( + # Required: one or more DNS names (or IP addresses) + [Parameter(Mandatory = $false, ParameterSetName = 'DnsName')] + [string[]] + $DnsName, + + [Parameter(Mandatory = $false, ParameterSetName = 'DnsName')] + [switch] + $Loopback, + + # Subject parts + [Parameter(Mandatory = $false, ParameterSetName = 'CommonName')] + [string] + $CommonName = 'SelfSigned', + + [Parameter()] + [securestring] + $Password = $null, + + [Parameter()] + [string]$Organization, + [Parameter()] + [string]$Locality, + [Parameter()] + [string]$State, + [Parameter()] + [string]$Country = 'XX', + + # Key type and size + [Parameter()] + [ValidateSet('RSA', 'ECDSA')] + [string] + $KeyType = 'RSA', + + [Parameter()] + [ValidateSet(2048, 3072, 4096, 256, 384, 521)] + [int] + $KeyLength = 2048, + + # Enhanced Key Usages (EKU) - e.g., '1.3.6.1.5.5.7.3.1' for server auth + [Parameter()] + [string[]]$EnhancedKeyUsages, + + [Parameter()] + [ValidateSet('ServerAuth', 'ClientAuth', 'CodeSigning', 'EmailSecurity', 'Custom')] + [string] + $CertificatePurpose = 'ServerAuth', + + # Optional NotBefore date for certificate validity start + [Parameter()] + [DateTime] + $NotBefore, + + # Additional custom extensions (as an array of extension objects) + [Parameter()] + [object[]] + $CustomExtensions, + + # Friendly name for display in certificate stores + [Parameter()] + [string] + $FriendlyName = 'MyCertificate', + + # Validity period (in days) + [Parameter()] + [int] + $ValidityDays = 365, + + [Parameter()] + [switch] + $Ephemeral, + + [Parameter()] + [switch] + $Exportable + + ) + + # Handle Loopback Parameter + if ($Loopback) { + if ($null -eq $DnsName) { + $DnsName = @() + } + if ($DnsName -notcontains '127.0.0.1') { + $DnsName += '127.0.0.1' + } + if ($DnsName -notcontains '::1') { + $DnsName += '::1' + } + if ($DnsName -notcontains 'localhost') { + $DnsName += 'localhost' + } + + # Add machine-specific names if available + if ((![string]::IsNullOrWhiteSpace($PodeContext.Server.ComputerName) ) -and ($DnsName -notcontains $PodeContext.Server.ComputerName)) { + $DnsName += $PodeContext.Server.ComputerName + } + # Add machine-specific fqdn if available + if ((![string]::IsNullOrWhiteSpace($PodeContext.Server.Fqdn)) -and + ($PodeContext.Server.Fqdn -ne $PodeContext.Server.ComputerName) -and ($DnsName -notcontains $PodeContext.Server.Fqdn)) { + $DnsName += $PodeContext.Server.Fqdn + } + } + + # Call the certificate request function to generate the CSR and key pair. + $csrParams = @{ + DnsName = $DnsName + CommonName = $CommonName + Organization = $Organization + Locality = $Locality + State = $State + Country = $Country + KeyType = $KeyType + KeyLength = $KeyLength + CertificatePurpose = $CertificatePurpose + EnhancedKeyUsages = $EnhancedKeyUsages + CustomExtensions = $CustomExtensions + } + + $csrObject = New-PodeCertificateRequestInternal @csrParams + + # Determine certificate validity dates. + if ($null -eq $NotBefore) { $NotBefore = ([datetime]::UtcNow) } + $startDate = $NotBefore + $endDate = $NotBefore.AddDays($ValidityDays) + + try { + # Create the self-signed certificate from the CSR. + $cert = $csrObject.CertificateRequest.CreateSelfSigned( + [System.DateTimeOffset]::new($startDate), + [System.DateTimeOffset]::new($endDate) + ) + + # Set the friendly name if provided. + if (![string]::IsNullOrEmpty($FriendlyName) -and (Test-PodeIsWindows)) { + $cert.FriendlyName = $FriendlyName + } + + # Export the certificate as a PFX (with a default password; adjust as needed). + $pfxBytes = $cert.Export([System.Security.Cryptography.X509Certificates.X509ContentType]::Pfx , $Password) + + if ($Ephemeral -and !$IsMacOS) { + $storageFlags = [System.Security.Cryptography.X509Certificates.X509KeyStorageFlags]::EphemeralKeySet + } + else { + $storageFlags = [System.Security.Cryptography.X509Certificates.X509KeyStorageFlags]::DefaultKeySet + } + + if ($Exportable) { + $storageFlags = $storageFlags -bor [System.Security.Cryptography.X509Certificates.X509KeyStorageFlags]::Exportable + } + $finalCert = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new( + $pfxBytes, + $Password, + $storageFlags + ) + return $finalCert + } + catch { + $_ | Write-PodeErrorLog + throw + } +} + +<# +.SYNOPSIS + Imports an X.509 certificate from a file (PFX, PEM, CER) or retrieves it from the Windows certificate store. + +.DESCRIPTION + This function imports an X.509 certificate using one of three methods: + - From a certificate file (PFX, PEM, or CER). + - From the Windows certificate store by thumbprint. + - From the Windows certificate store by subject name. + + By default, the certificate is imported with an **ephemeral key**, meaning the private key + exists **only for the current session** and is not persisted. If the `-Persistent` flag is + specified, the private key will be stored in an exportable format. + +.PARAMETER Path + The path to the certificate file (.pfx, .pem, or .cer) to import. + +.PARAMETER PrivateKeyPath + The path to a separate private key file (for PEM format). + Required if the certificate file does not contain the private key. + +.PARAMETER CertificatePassword + A secure string containing the password for decrypting a PFX certificate + or an encrypted private key in PEM format. + +.PARAMETER Exportable + If specified, the certificate will be imported with an **exportable** private key, + allowing it to be saved and reused across sessions. + + If not specified, the certificate will be imported **ephemerally**, meaning the + private key will exist **only in memory** and will be lost when the process exits. + +.PARAMETER CertificateThumbprint + The thumbprint of a certificate stored in the Windows certificate store. + +.PARAMETER CertificateName + The subject name of a certificate stored in the Windows certificate store. + +.PARAMETER CertificateStoreName + The name of the Windows certificate store to search in when retrieving a certificate + by thumbprint or subject name. Defaults to "My". + +.PARAMETER CertificateStoreLocation + The location of the Windows certificate store. Defaults to "CurrentUser". + +.OUTPUTS + [System.Security.Cryptography.X509Certificates.X509Certificate2] + Returns the imported certificate as an X509Certificate2 object. + +.EXAMPLE + $cert = Import-PodeCertificate -Path "C:\Certs\mycert.pfx" -CertificatePassword (ConvertTo-SecureString -String "MyPass" -AsPlainText -Force) + Imports a PFX certificate file with an ephemeral private key. + +.EXAMPLE + $cert = Import-PodeCertificate -Path "C:\Certs\mycert.pfx" -CertificatePassword (ConvertTo-SecureString -String "MyPass" -AsPlainText -Force) -Persistent + Imports a PFX certificate file **with a persistent private key**, allowing it to be saved. + +.EXAMPLE + $cert = Import-PodeCertificate -Path "C:\Certs\mycert.cer" + Imports a CER certificate file (public key only). + +.EXAMPLE + $cert = Import-PodeCertificate -CertificateThumbprint "D2C2F4F7A456B69D4F9E9F8C3D3D6E5A9C3EBA6F" + Retrieves a certificate from the Windows certificate store using its thumbprint. + +.EXAMPLE + $cert = Import-PodeCertificate -CertificateName "MyAppCert" -CertificateStoreName "Root" -CertificateStoreLocation "LocalMachine" + Retrieves a certificate by subject name from the LocalMachine\Root store. + +.NOTES + - The `-Persistent` flag should be used when you need to store the certificate for future use. + - The default behavior (`EphemeralKeySet`) ensures the private key does not persist in the system. + - When using a PEM certificate, ensure the private key is available if required. + - Windows certificate store retrieval is only supported on Windows systems. + - CER files contain only the public key and do not support private key decryption. + - The improrted Certificate is not validated and returned as is. +#> +function Import-PodeCertificate { + param ( + # Certificate-based parameters for RSA/ECDSA + [Parameter(Mandatory = $true, ParameterSetName = 'CertFile')] + [string] + $Path, + + [Parameter(Mandatory = $false, ParameterSetName = 'CertFile')] + [string] + $PrivateKeyPath = $null, + + [Parameter(Mandatory = $false, ParameterSetName = 'CertFile')] + [SecureString] + $CertificatePassword, + + [Parameter(Mandatory = $false, ParameterSetName = 'CertFile')] + [Parameter()] + [switch]$Exportable, + + [Parameter(Mandatory = $true, ParameterSetName = 'CertThumb')] + [string] + $CertificateThumbprint, + + [Parameter(Mandatory = $true, ParameterSetName = 'CertName')] + [string] + $CertificateName, + + [Parameter(ParameterSetName = 'CertName')] + [Parameter(ParameterSetName = 'CertThumb')] + [System.Security.Cryptography.X509Certificates.StoreName] + $CertificateStoreName = 'My', + + [Parameter(ParameterSetName = 'CertName')] + [Parameter(ParameterSetName = 'CertThumb')] + [System.Security.Cryptography.X509Certificates.StoreLocation] + $CertificateStoreLocation = 'CurrentUser' + ) + + switch ($PSCmdlet.ParameterSetName) { + 'CertFile' { + # If using a file-based certificate, ensure it exists, then load it + if (!(Test-Path -Path $Path -PathType Leaf)) { + throw ($PodeLocale.pathNotExistExceptionMessage -f $Path) + } + if (![string]::IsNullOrEmpty($PrivateKeyPath) -and !(Test-Path -Path $PrivateKeyPath -PathType Leaf)) { + throw ($PodeLocale.pathNotExistExceptionMessage -f $PrivateKeyPath) + } + $X509Certificate = Get-PodeCertificateByFile -Certificate $Path -SecurePassword $CertificatePassword -PrivateKeyPath $PrivateKeyPath -Exportable:$Exportable + break + } + + 'certthumb' { + # Retrieve a certificate from the local store by thumbprint + $X509Certificate = Get-PodeCertificateByThumbprint -Thumbprint $CertificateThumbprint -StoreName $CertificateStoreName -StoreLocation $CertificateStoreLocation + } + + 'certname' { + # Retrieve a certificate from the local store by name + $X509Certificate = Get-PodeCertificateByName -Name $CertificateName -StoreName $CertificateStoreName -StoreLocation $CertificateStoreLocation + } + } + + return $X509Certificate +} + + +<# +.SYNOPSIS + Exports an X.509 certificate to a file (PFX, PEM, or CER) or installs it into the Windows certificate store. + +.DESCRIPTION + This function exports an X.509 certificate in various formats: + - PFX (PKCS#12) with optional password protection. + - PEM (Base64-encoded format), optionally including the private key. + - CER (DER-encoded format). + It also allows storing the certificate in the Windows certificate store. + + The function supports exporting private keys (if available) for PEM format, encrypting them if a password is provided. + +.PARAMETER Certificate + The X509Certificate2 object to export. This must be a valid certificate. + +.PARAMETER Path + The output file path (without an extension) where the certificate will be saved. + Defaults to the current working directory with the certificate subject name. + +.PARAMETER Format + The format in which to export the certificate. Supported values: 'PFX', 'PEM', 'CER'. + Defaults to 'PFX'. + +.PARAMETER CertificatePassword + A secure string containing the password for exporting the PFX format + or encrypting the private key in PEM format. + +.PARAMETER IncludePrivateKey + When exporting in PEM format, this flag includes the private key in a separate `.key` file. + +.PARAMETER CertificateStoreName + The Windows certificate store name where the certificate should be installed. + This parameter is required when using the 'WindowsStore' parameter set. + +.PARAMETER CertificateStoreLocation + The location of the Windows certificate store. Defaults to 'CurrentUser'. + This parameter is required when using the 'WindowsStore' parameter set. + +.OUTPUTS + [string] or [hashtable] + - If exporting to a file, returns the full file path(s) of the exported certificate. + - If storing in Windows, returns `$true` if successful, `$false` otherwise. + +.EXAMPLE + $cert = Get-PodeCertificate -Path "mycert.pfx" -Password (ConvertTo-SecureString -String "MyPass" -AsPlainText -Force) + Export-PodeCertificate -Certificate $cert -Path "C:\Certs\mycert" -Format "PEM" -IncludePrivateKey + + Exports the certificate as a PEM file with a separate private key file. + +.EXAMPLE + $cert = Get-PodeCertificate -Path "mycert.pfx" -Password (ConvertTo-SecureString -String "MyPass" -AsPlainText -Force) + Export-PodeCertificate -Certificate $cert -CertificateStoreName "My" -CertificateStoreLocation "LocalMachine" + + Stores the certificate in the LocalMachine certificate store under "My". + +.NOTES + - This function integrates with Pode’s certificate handling utilities. + - Windows store installation is only available on Windows. + - PEM format supports exporting the private key separately, which can be encrypted with a password. +#> +function Export-PodeCertificate { + [CmdletBinding(DefaultParameterSetName = 'File')] + param ( + # The X509 Certificate object to export + [Parameter(Mandatory = $true, ValueFromPipeline = $true)] + [System.Security.Cryptography.X509Certificates.X509Certificate2] + $Certificate, + + [Parameter(Mandatory = $false, ParameterSetName = 'File')] + [string] + $Path, + + [Parameter(Mandatory = $false, ParameterSetName = 'File')] + [ValidateSet('PFX', 'PEM', 'CER')] + [string] + $Format = 'PFX', + + [Parameter(Mandatory = $false, ParameterSetName = 'File')] + [SecureString] + $CertificatePassword, + + [Parameter(Mandatory = $false, ParameterSetName = 'File')] + [switch]$IncludePrivateKey, + + [Parameter(Mandatory = $true, ParameterSetName = 'WindowsStore')] + [System.Security.Cryptography.X509Certificates.StoreName] + $CertificateStoreName, + + [Parameter(Mandatory = $true, ParameterSetName = 'WindowsStore')] + [System.Security.Cryptography.X509Certificates.StoreLocation] + $CertificateStoreLocation = 'CurrentUser' + ) + + process { + + + switch ($PSCmdlet.ParameterSetName) { + 'File' { + if (Test-Path -Path $Path -PathType Container) { + # Extract CN (Common Name) from Subject (ensures it only grabs CN=XX) + if ($Certificate.Subject -match 'CN=([^,]+)') { + $baseName = $matches[1] + } + else { + $baseName = $Certificate.Thumbprint # Fallback to thumbprint + } + + # Replace invalid filename characters and normalize spaces + $baseName = $baseName -replace '[\\/:*?"<>|]', '_' -replace '\s+', '_' + + $filePath = Join-Path -Path $($PodeContext.Server.Root) -ChildPath $baseName + } + else { + $filePath = $Path + } + + switch ($Format) { + 'PFX' { + $pfxBytes = if ($CertificatePassword) { + $Certificate.Export([System.Security.Cryptography.X509Certificates.X509ContentType]::Pfx, $CertificatePassword) + } + else { + $Certificate.Export([System.Security.Cryptography.X509Certificates.X509ContentType]::Pfx) + } + + $filePathWithExt = [PSCustomObject]@{ CertificateFile = "$FilePath.pfx" } + [System.IO.File]::WriteAllBytes($filePathWithExt.CertificateFile, $pfxBytes) + break + } + 'CER' { + $cerBytes = $Certificate.Export([System.Security.Cryptography.X509Certificates.X509ContentType]::Cert) + + $filePathWithExt = [PSCustomObject]@{ CertificateFile = "$FilePath.cer" } + [System.IO.File]::WriteAllBytes($filePathWithExt.CertificateFile, $cerBytes) + break + } + 'PEM' { + if ($PSVersionTable.PSVersion.Major -lt 7) { + throw ($PodeLocale.pemCertificateNotSupportedByPwshVersionExceptionMessage -f $PSVersionTable.PSVersion) + } + # Export the certificate in PEM format + $pemCert = "-----BEGIN CERTIFICATE-----`n" + $pemCert += [Convert]::ToBase64String($Certificate.RawData, 'InsertLineBreaks') + $pemCert += "`n-----END CERTIFICATE-----" + $certFilePath = "$FilePath.pem" + $pemCert | Out-File -FilePath $certFilePath -Encoding utf8NoBOM + + Write-Verbose "Certificate exported successfully: $certFilePath" + + # If requested, export the private key to a separate file + if ($IncludePrivateKey -and $Certificate.HasPrivateKey) { + $pemKey = Export-PodePrivateKeyPem -Key $Certificate.PrivateKey -Password $CertificatePassword + $keyFilePath = "$FilePath.key" + $pemKey | Out-File -FilePath $keyFilePath -Encoding utf8NoBOM + + Write-Verbose "Private key exported successfully: $keyFilePath" + } + + # Return the certificate file path (and key file path if applicable) + $filePathWithExt = if ($IncludePrivateKey -and $Certificate.HasPrivateKey) { + [PSCustomObject]@{ CertificateFile = $certFilePath; PrivateKeyFile = $keyFilePath } + } + else { + [PSCustomObject]@{ CertificateFile = $certFilePath } + } + break + } + } + + if ($Format -ne 'PEM') { + Write-Verbose "Certificate exported successfully: $($filePathWithExt.CertificateFile)" + } + return $filePathWithExt + } + + 'WindowsStore' { + if (Test-PodeIsWindows) { + $store = [System.Security.Cryptography.X509Certificates.X509Store]::new($CertificateStoreName, $CertificateStoreLocation) + $store.Open('ReadWrite') + $store.Add($Certificate) + $store.Close() + + Write-Verbose "Certificate successfully stored in: $CertificateStoreLocation\$CertificateStoreName" + return [PSCustomObject]@{CertificateStore = "$CertificateStoreLocation\$CertificateStoreName" } + } + return $null + } + } + } +} + +<# +.SYNOPSIS + Retrieves the Enhanced Key Usage (EKU) purposes of an X.509 certificate. + +.DESCRIPTION + This internal function extracts the Enhanced Key Usage (EKU) extension (OID: 2.5.29.37) + from an X.509 certificate and returns the recognized purposes. + + If the certificate has no EKU extension, an empty array is returned, indicating + that the certificate has no usage restrictions. + +.PARAMETER Certificate + The X509Certificate2 object from which to retrieve the EKU purposes. + +.OUTPUTS + [object[]] + Returns an array of recognized EKU purposes. Supported values: + - 'ServerAuth' (1.3.6.1.5.5.7.3.1) + - 'ClientAuth' (1.3.6.1.5.5.7.3.2) + - 'CodeSigning' (1.3.6.1.5.5.7.3.3) + - 'EmailSecurity' (1.3.6.1.5.5.7.3.4) + + If an unrecognized EKU OID is found, it is returned as `"Unknown ()"`. + If no EKU extension is present, an empty array is returned. + +.EXAMPLE + $purposes = Get-PodeCertificatePurpose -Certificate $cert + Retrieves the list of EKU purposes assigned to the given certificate. + +#> +function Get-PodeCertificatePurpose { + [CmdletBinding()] + [OutputType([object[]])] + param ( + [Parameter(Mandatory = $true, ValueFromPipeline = $true)] + [System.Security.Cryptography.X509Certificates.X509Certificate2]$Certificate + ) + process { + # Define known EKU OIDs and their purposes + $purposeOids = @{ + '1.3.6.1.5.5.7.3.1' = 'ServerAuth' + '1.3.6.1.5.5.7.3.2' = 'ClientAuth' + '1.3.6.1.5.5.7.3.3' = 'CodeSigning' + '1.3.6.1.5.5.7.3.4' = 'EmailSecurity' + } + + # Retrieve the EKU extension (OID: 2.5.29.37) + $ekuExtension = $Certificate.Extensions | Where-Object { $_.Oid.Value -eq '2.5.29.37' } + + if ($ekuExtension -and $ekuExtension -is [System.Security.Cryptography.X509Certificates.X509EnhancedKeyUsageExtension]) { + # Use the EnhancedKeyUsages property which returns an OidCollection + $purposes = @() + foreach ($oid in $ekuExtension.EnhancedKeyUsages) { + if ($purposeOids.ContainsKey($oid.Value)) { + $purposes += $purposeOids[$oid.Value] + } + else { + $purposes += "Unknown ($($oid.Value))" + } + } + return $purposes + } + + # If no EKU is present, return an empty array (no restrictions) + return @() + } +} + +<# +.SYNOPSIS + Validates an X.509 certificate for both general validity and intended usage. + +.DESCRIPTION + This function performs comprehensive validation on an X.509 certificate. It checks: + - That the certificate’s validity period (NotBefore and NotAfter) is current. + - That the certificate chain is valid (including optional revocation checking). + - That the certificate meets security criteria (e.g. not using weak algorithms). + - Optionally, that the certificate’s Enhanced Key Usage (EKU) includes the expected purpose. + + New parameters: + - **ExpectedPurpose**: When provided, the function checks if the certificate’s EKU includes this purpose. + Valid values: ServerAuth, ClientAuth, CodeSigning, EmailSecurity. + - **Strict**: When used with ExpectedPurpose, if any unknown EKU is present, validation fails. + - **AllowWeakAlgorithms**: When specified, certificates using weak algorithms are allowed. + - **DenySelfSigned**: When specified, self-signed certificates are rejected. + + If any validation step fails, the function writes an error and returns `$false`. Otherwise, it returns `$true`. + +.PARAMETER Certificate + The X509Certificate2 object to validate. + +.PARAMETER CheckRevocation + A switch that enables revocation checking (online or offline). + +.PARAMETER OfflineRevocation + A switch that forces revocation checking to use only cached CRLs. + +.PARAMETER AllowWeakAlgorithms + A switch that, when provided, allows certificates with weak signature algorithms. + +.PARAMETER DenySelfSigned + A switch that, when provided, rejects self-signed certificates. + +.PARAMETER ExpectedPurpose + An optional string specifying the expected Enhanced Key Usage (EKU) for the certificate. + Valid values: ServerAuth, ClientAuth, CodeSigning, EmailSecurity. + - 'ServerAuth' (1.3.6.1.5.5.7.3.1) + - 'ClientAuth' (1.3.6.1.5.5.7.3.2) + - 'CodeSigning' (1.3.6.1.5.5.7.3.3) + - 'EmailSecurity' (1.3.6.1.5.5.7.3.4) + +.PARAMETER Strict + A switch that, when used with ExpectedPurpose, enforces that no unknown EKUs are present. + +.OUTPUTS + [boolean] Returns `$true` if the certificate passes all validation and restriction checks, otherwise `$false`. + +.EXAMPLE + Test-PodeCertificate -Certificate $cert + Performs basic validity and chain checks on the certificate. + +.EXAMPLE + Test-PodeCertificate -Certificate $cert -CheckRevocation + Also performs online revocation checking. + +.EXAMPLE + Test-PodeCertificate -Certificate $cert -ExpectedPurpose CodeSigning -Strict + Validates the certificate and ensures it is explicitly intended for CodeSigning. +#> +function Test-PodeCertificate { + [CmdletBinding()] + [OutputType([bool])] + param ( + [Parameter(Mandatory = $true, ValueFromPipeline = $true)] + [System.Security.Cryptography.X509Certificates.X509Certificate2] + $Certificate, + + [Parameter()] + [switch]$CheckRevocation, + + [Parameter()] + [switch]$OfflineRevocation, + + [Parameter()] + [switch]$AllowWeakAlgorithms, + + [Parameter()] + [switch]$DenySelfSigned, + + [Parameter()] + [ValidateSet('ServerAuth', 'ClientAuth', 'CodeSigning', 'EmailSecurity')] + [string]$ExpectedPurpose, + + [Parameter()] + [switch]$Strict + ) + process { + # Validate certificate validity period + $currentDate = [System.DateTime]::UtcNow + $notBefore = $Certificate.NotBefore.ToUniversalTime() + $notAfter = $Certificate.NotAfter.ToUniversalTime() + + if ($currentDate -lt $notBefore) { + Write-Error ($PodeLocale.certificateNotValidYetExceptionMessage -f $Certificate.Subject, $notBefore) + return $false + } + if ($currentDate -gt $notAfter) { + Write-Error ($PodeLocale.certificateExpiredExceptionMessage -f $Certificate.Subject, $notAfter) + return $false + } + Write-Verbose "Certificate $($Certificate.Subject) is within its valid period." + + # Option: Deny self-signed certificates if requested. + if ($DenySelfSigned -and ($Certificate.Subject -eq $Certificate.Issuer)) { + Write-Error $PodeLocale.selfSignedCertificatesNotAllowedExceptionMessage + return $false + } + + # For CA-issued certificates, check signature validity. + # Self-signed certificates: skip signature verification but log a message. + if ($Certificate.Subject -ne $Certificate.Issuer) { + if (! $Certificate.Verify()) { + Write-Error ($PodeLocale.certificateSignatureInvalidExceptionMessage -f $Certificate.Subject) + return $false + } + } + else { + Write-Verbose 'Self-signed certificate detected: skipping signature verification.' + } + + # Initialize the certificate chain. + $chain = [System.Security.Cryptography.X509Certificates.X509Chain]::new() + + # For self-signed certificates, allow an unknown certificate authority and disable revocation checks. + if ($Certificate.Subject -eq $Certificate.Issuer) { + $chain.ChainPolicy.VerificationFlags = [System.Security.Cryptography.X509Certificates.X509VerificationFlags]::AllowUnknownCertificateAuthority + $CheckRevocation = $false + Write-Verbose 'Self-signed certificate detected: revocation check disabled.' + } + + # Apply revocation policy. + if ($CheckRevocation) { + $chain.ChainPolicy.RevocationMode = if ($OfflineRevocation) { + [System.Security.Cryptography.X509Certificates.X509RevocationMode]::Offline + } + else { + [System.Security.Cryptography.X509Certificates.X509RevocationMode]::Online + } + Write-Verbose "Revocation checking set to: $($chain.ChainPolicy.RevocationMode)" + } + else { + $chain.ChainPolicy.RevocationMode = [System.Security.Cryptography.X509Certificates.X509RevocationMode]::NoCheck + } + + # Build the certificate chain. + $isValidChain = $chain.Build($Certificate) + if (-not $isValidChain) { + foreach ($status in $chain.ChainStatus) { + if ($status.Status -eq [System.Security.Cryptography.X509Certificates.X509ChainStatusFlags]::UntrustedRoot) { + Write-Error ($PodeLocale.certificateUntrustedRootExceptionMessage -f $Certificate.Subject) + return $false + } + if ($status.Status -eq [System.Security.Cryptography.X509Certificates.X509ChainStatusFlags]::Revoked) { + Write-Error ($PodeLocale.certificateRevokedExceptionMessage -f $Certificate.Subject, $status.StatusInformation) + return $false + } + if ($status.Status -eq [System.Security.Cryptography.X509Certificates.X509ChainStatusFlags]::NotTimeValid) { + Write-Error ($PodeLocale.certificateExpiredIntermediateExceptionMessage -f $Certificate.Subject) + return $false + } + } + Write-Error ($PodeLocale.certificateValidationFailedExceptionMessage -f $Certificate.Subject) + return $false + } + Write-Verbose 'Certificate chain validation successful.' + + # Check for weak algorithms unless weak ones are allowed. + if (-not $AllowWeakAlgorithms) { + $weakAlgorithms = @('md5RSA', 'sha1RSA', 'sha1ECDSA', 'RSA-1024') + if ($Certificate.SignatureAlgorithm.FriendlyName -in $weakAlgorithms) { + Write-Error ($PodeLocale.certificateWeakAlgorithmExceptionMessage -f $Certificate.Subject, $Certificate.SignatureAlgorithm.FriendlyName) + return $false + } + } + + # If an ExpectedPurpose is provided, check the certificate's EKU restrictions. + if ($ExpectedPurpose) { + # Retrieve the EKU values via a helper function. + $purposes = Get-PodeCertificatePurpose -Certificate $Certificate + if ($purposes.Count -eq 0 -and ! $Strict) { + Write-Verbose 'Certificate has no EKU restrictions; it can be used for any purpose.' + } + elseif ($ExpectedPurpose -notin $purposes) { + Write-Error ($PodeLocale.certificateNotValidForPurposeExceptionMessage -f $ExpectedPurpose, ($purposes -join ', ')) + return $false + } + if ($Strict -and ($purposes -match '^Unknown')) { + Write-Error ($PodeLocale.certificateUnknownEkusStrictModeExceptionMessage -f ($purposes -join ', ')) + return $false + } + Write-Verbose "Certificate is valid for the expected purpose '$ExpectedPurpose'. Found purposes: $($purposes -join ', ')" + } + + return $true + } +} \ No newline at end of file diff --git a/src/Public/Core.ps1 b/src/Public/Core.ps1 index ef83806eb..752d56a20 100644 --- a/src/Public/Core.ps1 +++ b/src/Public/Core.ps1 @@ -85,6 +85,9 @@ .PARAMETER Daemon Configures the server to run as a daemon with minimal console interaction and output. +.PARAMETER ApplicationName + Specifies the name of the Pode application. If not provided, the default is the script's file name (excluding the extension). + .EXAMPLE Start-PodeServer { /* server logic */ } Starts a Pode server using the supplied script block. @@ -208,7 +211,10 @@ function Start-PodeServer { [Parameter(Mandatory = $true, ParameterSetName = 'FileDaemon')] [Parameter(Mandatory = $true, ParameterSetName = 'ScriptDaemon')] [switch] - $Daemon + $Daemon, + + [string] + $ApplicationName ) begin { @@ -224,8 +230,13 @@ function Start-PodeServer { throw ($PodeLocale.fnDoesNotAcceptArrayAsPipelineInputExceptionMessage -f $($MyInvocation.MyCommand.Name)) } # Store the name of the current runspace $previousRunspaceName = Get-PodeCurrentRunspaceName + + if ([string]::IsNullOrEmpty($ApplicationName)) { + $ApplicationName = (Get-PodeApplicationName) + } + # Sets the name of the current runspace - Set-PodeCurrentRunspaceName -Name 'PodeServer' + Set-PodeCurrentRunspaceName -Name $ApplicationName # ensure the session is clean $Script:PodeContext = $null @@ -268,6 +279,7 @@ function Start-PodeServer { EnableBreakpoints = $EnableBreakpoints IgnoreServerConfig = $IgnoreServerConfig ConfigFile = $ConfigFile + ApplicationName = $ApplicationName Daemon = $Daemon } diff --git a/src/Public/Endpoint.ps1 b/src/Public/Endpoint.ps1 index d6b1c97c1..6afebc737 100644 --- a/src/Public/Endpoint.ps1 +++ b/src/Public/Endpoint.ps1 @@ -58,7 +58,7 @@ A quick description of the Endpoint - normally used in OpenAPI. An optional Acknowledge message to send to clients when they first connect, for TCP and SMTP endpoints only. .PARAMETER SslProtocol -One or more optional SSL Protocols this endpoints supports. (Default: SSL3/TLS12 - Just TLS12 on MacOS). +One or more optional SSL Protocols this endpoints supports. (Default: what the OS support by default). .PARAMETER CRLFMessageEnd If supplied, TCP endpoints will expect incoming data to end with CRLF. @@ -67,7 +67,7 @@ If supplied, TCP endpoints will expect incoming data to end with CRLF. Ignore Adminstrator checks for non-localhost endpoints. .PARAMETER SelfSigned -Create and bind a self-signed certifcate for HTTPS endpoints. +Create and bind a self-signed certificate for HTTPS endpoints. .PARAMETER AllowClientCertificate Allow for client certificates to be sent on requests. @@ -126,7 +126,7 @@ function Add-PodeEndpoint { $Certificate = $null, [Parameter(ParameterSetName = 'CertFile')] - [string] + [object] $CertificatePassword = $null, [Parameter(ParameterSetName = 'CertFile')] @@ -385,7 +385,15 @@ function Add-PodeEndpoint { switch ($PSCmdlet.ParameterSetName.ToLowerInvariant()) { 'certfile' { - $obj.Certificate.Raw = Get-PodeCertificateByFile -Certificate $Certificate -Password $CertificatePassword -Key $CertificateKey + if ($CertificatePassword -is [string]) { + $securePassword = ConvertTo-SecureString -String $CertificatePassword -AsPlainText -Force + } + elseif ($CertificatePassword -is [securestring]) { $securePassword = $CertificatePassword }else { + # 'Error: Invalid type for {0}. Expected {1}, but received [{2}]. + throw ($PodeLocale.invalidTypeExceptionMessage -f '-CertificatePassword', '[string] or [SecureString]', $CertificatePassword.GetType().Name) + } + + $obj.Certificate.Raw = Get-PodeCertificateByFile -Certificate $Certificate -SecurePassword $securePassword -PrivateKeyPath $CertificateKey } 'certthumb' { @@ -397,14 +405,17 @@ function Add-PodeEndpoint { } 'certself' { - $obj.Certificate.Raw = New-PodeSelfSignedCertificate + $obj.Certificate.Raw = New-PodeSelfSignedCertificate -Loopback -CertificatePurpose ServerAuth -DnsName $obj.address.ToString() } } - # fail if the cert is expired - if ($obj.Certificate.Raw.NotAfter -lt [datetime]::Now) { - # The certificate has expired - throw ($PodeLocale.certificateExpiredExceptionMessage -f $obj.Certificate.Raw.Subject, $obj.Certificate.Raw.NotAfter) + # Skip certificate validation if it has been explicitly provided as a variable. + if ($PSCmdlet.ParameterSetName -ne 'CertRaw') { + # Validate that the certificate: + # 1. Is within its validity period. + # 2. Has a valid certificate chain. + # 3. Is explicitly authorized for the expected purpose (ServerAuth). + $null = Test-PodeCertificate -Certificate $obj.Certificate.Raw -ExpectedPurpose ServerAuth -ErrorAction Stop } } diff --git a/src/Public/Jwt.ps1 b/src/Public/Jwt.ps1 new file mode 100644 index 000000000..4fb71b3e8 --- /dev/null +++ b/src/Public/Jwt.ps1 @@ -0,0 +1,979 @@ +using namespace System.Security.Cryptography +<# +.SYNOPSIS + Validates a JWT payload by checking its registered claims as defined in RFC 7519. + +.DESCRIPTION + This function verifies the validity of a JWT payload by ensuring: + - The `exp` (Expiration Time) has not passed. + - The `nbf` (Not Before) time is not in the future. + - The `iat` (Issued At) time is not in the future. + - The `iss` (Issuer) claim is valid based on the verification mode. + - The `sub` (Subject) claim is a valid string. + - The `aud` (Audience) claim is valid based on the verification mode. + - The `jti` (JWT ID) claim is a valid string. + +.PARAMETER Payload + The JWT payload as a [pscustomobject] containing registered claims such as `exp`, `nbf`, `iat`, `iss`, `sub`, `aud`, and `jti`. + +.PARAMETER Issuer + The expected JWT Issuer. If omitted, uses 'Pode'. + +.PARAMETER JwtVerificationMode + Defines how aggressively JWT claims should be checked: + - `Strict`: Requires all standard claims to be valid (`exp`, `nbf`, `iat`, `iss`, `aud`, `jti`). + - `Moderate`: Allows missing `iss` and `aud` but still checks expiration. + - `Lenient`: Ignores missing `iss` and `aud`, only verifies `exp`, `nbf`, and `iat`. + +.EXAMPLE + $payload = [pscustomobject]@{ + iss = "auth.example.com" + sub = "1234567890" + aud = "myapi.example.com" + exp = 1700000000 + nbf = 1690000000 + iat = 1690000000 + jti = "unique-token-id" + } + + Test-PodeJwt -Payload $payload -JwtVerificationMode "Strict" + + This example validates a JWT payload with full claim verification. +#> +function Test-PodeJwt { + [CmdletBinding()] + param( + [Parameter(Mandatory = $true)] + [pscustomobject] + $Payload, + + [Parameter()] + [string] + $Issuer = 'Pode', + + [Parameter()] + [ValidateSet('Strict', 'Moderate', 'Lenient')] + [string] + $JwtVerificationMode = 'Lenient' + ) + + # Get the current Unix timestamp for time-based checks + $currentUnix = [int][Math]::Floor(([DateTimeOffset]::new([DateTime]::UtcNow)).ToUnixTimeSeconds()) + + # Validate Expiration (`exp`) - applies to all verification modes + if ($Payload.exp) { + $expUnix = [long]$Payload.exp + if ($currentUnix -ge $expUnix) { + throw ($PodeLocale.jwtExpiredExceptionMessage) + } + } + + # Validate Not Before (`nbf`) - applies to all verification modes + if ($Payload.nbf) { + $nbfUnix = [long]$Payload.nbf + if ($currentUnix -lt $nbfUnix) { + throw ($PodeLocale.jwtNotYetValidExceptionMessage) + } + } + + # Validate Issued At (`iat`) - applies to all verification modes + if ($Payload.iat) { + $iatUnix = [long]$Payload.iat + if ($iatUnix -gt $currentUnix) { + throw ($PodeLocale.jwtIssuedInFutureExceptionMessage) + } + } + + # Validate Issuer (`iss`) if mode is Strict or Moderate + if ($JwtVerificationMode -eq 'Strict' -or $JwtVerificationMode -eq 'Moderate') { + if ($Payload.iss) { + # Check that the Issuer is a valid string and matches the expected Issuer + if (!$Payload.iss -or $Payload.iss -isnot [string] -or $Payload.iss -ne $Issuer) { + throw ($PodeLocale.jwtInvalidIssuerExceptionMessage -f $Issuer) + } + } + elseif ($JwtVerificationMode -eq 'Strict') { + # If the claim is missing in Strict mode, throw an error + throw ($PodeLocale.jwtMissingIssuerExceptionMessage) + } + } + + # Validate Audience (`aud`) if mode is Strict or Moderate + if ($JwtVerificationMode -eq 'Strict' -or $JwtVerificationMode -eq 'Moderate') { + if ($Payload.aud) { + # Ensure `aud` is either a string or an array of strings + if (!$Payload.aud -or ($Payload.aud -isnot [string] -and $Payload.aud -isnot [array])) { + throw ($PodeLocale.jwtInvalidAudienceExceptionMessage -f $PodeContext.Server.ApplicationName) + } + + # In Pode, check the application's name against `aud` + if ($Payload.aud -is [string]) { + if ($Payload.aud -ne $PodeContext.Server.ApplicationName) { + throw ($PodeLocale.jwtInvalidAudienceExceptionMessage -f $PodeContext.Server.ApplicationName) + } + } + elseif ($Payload.aud -is [array]) { + if ($Payload.aud -notcontains $PodeContext.Server.ApplicationName) { + throw ($PodeLocale.jwtInvalidAudienceExceptionMessage -f $PodeContext.Server.ApplicationName) + } + } + } + elseif ($JwtVerificationMode -eq 'Strict') { + # If `aud` is missing in Strict mode, throw an error + throw ($PodeLocale.jwtMissingAudienceExceptionMessage) + } + } + + # Validate Subject (`sub`) - applies to all verification modes + if ($Payload.sub) { + if (!$Payload.sub -or $Payload.sub -isnot [string]) { + throw ($PodeLocale.jwtInvalidSubjectExceptionMessage) + } + } + + # Validate JWT ID (`jti`) - only in Strict mode + if ($JwtVerificationMode -eq 'Strict') { + if ($Payload.jti) { + # Check that `jti` is a valid string + if (!$Payload.jti -or $Payload.jti -isnot [string]) { + throw ($PodeLocale.jwtInvalidJtiExceptionMessage) + } + } + else { + # `jti` must exist in Strict mode + throw ($PodeLocale.jwtMissingJtiExceptionMessage) + } + } +} + +<# +.SYNOPSIS + Converts a JWT token into a PowerShell object, optionally verifying its signature. + +.DESCRIPTION + The ConvertFrom-PodeJwt function takes a JWT token and decodes its header, payload, + and signature. By default, it verifies the signature using a specified secret, + certificate, or Pode authentication method. If IgnoreSignature is specified, + the function decodes and returns the token payload without verification. + +.PARAMETER Token + The JWT token to be decoded and optionally verified. + +.PARAMETER IgnoreSignature + Indicates that the JWT token signature should be ignored + and the payload returned directly without verification. + +.PARAMETER Outputs + Determines which parts of the JWT should be returned: + Header, Payload, Signature, or any combination thereof. Defaults to 'Payload'. + +.PARAMETER HumanReadable + Converts UNIX timestamps (e.g., iat, nbf, exp) into DateTime objects for easier reading. + +.PARAMETER Secret + A string or byte array used for HMAC-based signature verification. + +.PARAMETER Certificate + The path to a file containing an X.509 certificate for RSA/ECDSA signature verification. + +.PARAMETER PrivateKeyPath + The path to a PEM key file that pairs with the certificate + for RSA/ECDSA signature verification. + +.PARAMETER CertificatePassword + A SecureString containing a password for the certificate file, if required. + +.PARAMETER CertificateThumbprint + A thumbprint to retrieve a certificate from the Windows certificate store. + +.PARAMETER CertificateName + A subject name to retrieve a certificate from the Windows certificate store. + +.PARAMETER CertificateStoreName + The name of the Windows certificate store to search (default: My). + +.PARAMETER CertificateStoreLocation + The location of the Windows certificate store to search (default: CurrentUser). + +.PARAMETER X509Certificate + A raw X.509 certificate object used for RSA/ECDSA signature verification. + +.PARAMETER RsaPaddingScheme + Specifies the RSA padding scheme to use (Pkcs1V15 or Pss). + Defaults to Pkcs1V15. + +.PARAMETER Authentication + A Pode authentication method name whose configuration is used + for signature verification. + +.OUTPUTS + [pscustomobject] or [System.Collections.Specialized.OrderedDictionary]. + Returns one or more parts of the JWT (Header, Payload, Signature) + as PowerShell objects or dictionaries. + +.EXAMPLE + ConvertFrom-PodeJwt -Token $jwtToken -Secret 'mysecret' + Decodes and verifies the JWT token using an HMAC secret. + +.EXAMPLE + ConvertFrom-PodeJwt -Token $jwtToken -Certificate './certs/myCert.pem' + Decodes and verifies the JWT token using an X.509 certificate from a file. + +.NOTES + - This function is tailored for use with Pode, a PowerShell web server framework. + - When signature verification is enabled, the appropriate key or certificate must be provided. + - Use HTTPS in production to safeguard tokens. +#> + +function ConvertFrom-PodeJwt { + [CmdletBinding(DefaultParameterSetName = 'Default')] + [OutputType([pscustomobject])] + [OutputType([System.Collections.Specialized.OrderedDictionary])] + param( + [Parameter(Mandatory = $true, ParameterSetName = 'Secret')] + [Parameter(Mandatory = $true, ParameterSetName = 'CertName')] + [Parameter(Mandatory = $true, ParameterSetName = 'CertThumb')] + [Parameter(Mandatory = $true, ParameterSetName = 'CertRaw')] + [Parameter(Mandatory = $true, ParameterSetName = 'CertFile')] + [Parameter(Mandatory = $true, ParameterSetName = 'Ignore')] + [Parameter(Mandatory = $false, ParameterSetName = 'AuthenticationMethod')] + [string] + $Token, + + [Parameter(Mandatory = $false, ParameterSetName = 'Default')] + [Parameter(ParameterSetName = 'Ignore')] + [switch] + $IgnoreSignature, + + [ValidateSet('Header', 'Payload', 'Signature', 'Header,Payload', 'Header,Signature', 'Payload,Signature', 'Header,Payload,Signature')] + [string] + $Outputs = 'Payload', + + [switch] + $HumanReadable, + + [Parameter(Mandatory = $true, ParameterSetName = 'Secret')] + [object] + $Secret = $null, + + [Parameter(Mandatory = $true, ParameterSetName = 'CertFile')] + [string] + $Certificate, + + [Parameter(ParameterSetName = 'CertFile')] + [string] + $PrivateKeyPath = $null, + + [Parameter(Mandatory = $false, ParameterSetName = 'CertFile')] + [SecureString] + $CertificatePassword, + + [Parameter(Mandatory = $true, ParameterSetName = 'CertThumb')] + [string] + $CertificateThumbprint, + + [Parameter(Mandatory = $true, ParameterSetName = 'CertName')] + [string] + $CertificateName, + + [Parameter(ParameterSetName = 'CertName')] + [Parameter(ParameterSetName = 'CertThumb')] + [System.Security.Cryptography.X509Certificates.StoreName] + $CertificateStoreName = 'My', + + [Parameter(ParameterSetName = 'CertName')] + [Parameter(ParameterSetName = 'CertThumb')] + [System.Security.Cryptography.X509Certificates.StoreLocation] + $CertificateStoreLocation = 'CurrentUser', + + [Parameter(Mandatory = $true, ParameterSetName = 'CertRaw')] + [System.Security.Cryptography.X509Certificates.X509Certificate2] + $X509Certificate, + + [Parameter(Mandatory = $false, ParameterSetName = 'CertRaw')] + [Parameter(Mandatory = $false, ParameterSetName = 'CertFile')] + [Parameter(Mandatory = $false, ParameterSetName = 'CertName')] + [Parameter(Mandatory = $false, ParameterSetName = 'CertThumb')] + [ValidateSet('Pkcs1V15', 'Pss')] + [string] + $RsaPaddingScheme = 'Pkcs1V15', + + [Parameter(Mandatory = $true, ParameterSetName = 'AuthenticationMethod')] + [string] + $Authentication + ) + + # Identify which parameter set was chosen at runtime + $parameterSetName = $PSCmdlet.ParameterSetName + + # If set to 'Default', but a WebEvent context has an authentication name, switch to 'AuthenticationMethod' + if ($parameterSetName -eq 'Default') { + if ($null -ne $WebEvent -and $null -ne $WebEvent.Auth.Name) { + $parameterSetName = 'AuthenticationMethod' + $Authentication = $WebEvent.Auth.Name + } + } + + # Prepare a hashtable for parameters required for validation (e.g., certificate, secret, etc.) + # We'll populate it in the following switch statement. + $params = @{} + + # Depending on the chosen parameter set, load/prepare the resources for signature validation. + switch ($parameterSetName) { + 'CertFile' { + if (!(Test-Path -Path $Certificate -PathType Leaf)) { + throw ($PodeLocale.pathNotExistExceptionMessage -f $Certificate) + } + $X509Certificate = Get-PodeCertificateByFile -Certificate $Certificate -SecurePassword $CertificatePassword -PrivateKeyPath $PrivateKeyPath + } + 'CertThumb' { + $X509Certificate = Get-PodeCertificateByThumbprint -Thumbprint $CertificateThumbprint -StoreName $CertificateStoreName -StoreLocation $CertificateStoreLocation + } + 'CertName' { + $X509Certificate = Get-PodeCertificateByName -Name $CertificateName -StoreName $CertificateStoreName -StoreLocation $CertificateStoreLocation + } + 'Secret' { + if ($null -ne $Secret) { + if ($Secret -is [string]) { + $params = @{ Secret = ConvertTo-SecureString -String $Secret -AsPlainText -Force } + } + elseif ($Secret -is [byte[]]) { + $params = @{ Secret = [System.Text.Encoding]::UTF8.GetString($Secret) } + } + else { + $params = @{ Secret = $Secret } + } + } + else { + throw ($PodeLocale.missingKeyForAlgorithmExceptionMessage -f 'secret', 'HMAC', $Header['alg']) + } + } + 'CertRaw' { + if ($null -eq $X509Certificate) { + throw ($PodeLocale.missingKeyForAlgorithmExceptionMessage -f 'private', 'RSA/ECSDA', $Header['alg']) + } + } + 'AuthenticationMethod' { + # Validate that the specified authentication method exists in the current Pode context + if ($PodeContext -and $PodeContext.Server.Authentications.Methods.ContainsKey($Authentication)) { + $token = Get-PodeBearenToken + $authArgs = $PodeContext.Server.Authentications.Methods[$Authentication].Scheme.Arguments + if ($null -ne $authArgs.X509Certificate) { + $X509Certificate = $authArgs.X509Certificate + } + if ($null -ne $method.Secret) { + $params['Secret'] = $method.Secret + } + } + else { + throw ($PodeLocale.authenticationMethodDoesNotExistExceptionMessage) + } + } + 'Ignore' { + # If ignoring signature, no additional data is needed. + } + } + + if ($X509Certificate) { + # Skip certificate validation if it has been explicitly provided as a variable. + if ($PSCmdlet.ParameterSetName -ne 'CertRaw') { + # Validate that the certificate: + # 1. Is within its validity period. + # 2. Has a valid certificate chain. + # 3. Is explicitly authorized for the expected purpose (Code Signing). + # 4. Meets strict Enhanced Key Usage (EKU) enforcement. + $null = Test-PodeCertificate -Certificate $X509Certificate -ExpectedPurpose CodeSigning -Strict -ErrorAction Stop + } + + $params['X509Certificate'] = $X509Certificate + } + + $params['Token'] = $Token + + $parts = ($Token -split '\.') + # Verify that the token has exactly three parts + if ($parts.Length -ne 3) { + throw ($PodeLocale.invalidJwtSuppliedExceptionMessage) + } + + # Decode the header; this should contain the algorithm type (alg) + $header = ConvertFrom-PodeJwtBase64Value -Value $parts[0] + if ([string]::IsNullOrWhiteSpace($header.alg)) { + throw ($PodeLocale.invalidJwtHeaderAlgorithmSuppliedExceptionMessage) + } + + # Decode the payload; contains claims like sub, exp, iat, etc. + $payload = ConvertFrom-PodeJwtBase64Value -Value $parts[1] + + # Retrieve the signature part + $signature = $parts[2] + + # If ignoring the signature, return the payload immediately + if (! $IgnoreSignature) { + + + + # Some JWTs may specify "none" as the algorithm (no signature) + $isNoneAlg = ($header.alg -ieq 'none') + + # If signature is missing but an algorithm is expected, throw an error + if ([string]::IsNullOrWhiteSpace($signature) -and !$isNoneAlg) { + throw ($PodeLocale.noJwtSignatureForAlgorithmExceptionMessage -f $header.alg) + } + + # If "none" is indicated but a signature was supplied, throw an error + if (![string]::IsNullOrWhiteSpace($signature) -and $isNoneAlg) { + throw ($PodeLocale.expectedNoJwtSignatureSuppliedExceptionMessage) + } + + # If alg is "none" but a secret was provided, throw an error + if ($isNoneAlg -and ($null -ne $Secret) -and ($Secret.Length -gt 0)) { + throw ($PodeLocale.expectedNoJwtSignatureSuppliedExceptionMessage) + } + + # If "none" signature, return the payload since there's nothing to verify + if ($isNoneAlg) { + return $payload + } + + # At this point, we have a valid signature with a known algorithm + $params['Algorithm'] = $header.alg + + # Confirm-PodeJwt will finalize verification based on the algorithm and parameters + $null = Confirm-PodeJwt @params + } + + if ($HumanReadable) { + if ($payload.iat) { + $payload.iat = [System.DateTimeOffset]::FromUnixTimeSeconds($payload.iat).UtcDateTime + } + if ($payload.nbf) { + $payload.nbf = [System.DateTimeOffset]::FromUnixTimeSeconds($payload.nbf).UtcDateTime + } + if ($payload.exp) { + $payload.exp = [System.DateTimeOffset]::FromUnixTimeSeconds($payload.exp).UtcDateTime + } + } + + switch ($Outputs) { + 'Header' { + return $header + } + 'Payload' { + return $payload + } + 'Signature' { + return $signature + } + 'Header,Payload' { + return [ordered]@{Header = $header; Payload = $payload } + } + 'Header,Signature' { + return [ordered]@{Header = $header; Signature = $signature } + } + 'Payload,Signature' { + return [ordered]@{Payload = $payload; Signature = $signature } + } + 'Header,Payload,Signature' { + return [ordered]@{Header = $header; Payload = $payload; Signature = $signature } + } + default { + return $payload + } + } +} + +<# +.SYNOPSIS + Generates a JSON Web Token (JWT) based on the specified headers, payload, and signing credentials. +.DESCRIPTION + This function creates a JWT by combining a Base64URL-encoded header and payload. Depending on the + configured parameters, it supports various signing algorithms, including HMAC- and certificate-based + signatures. You can also omit a signature by specifying 'none'. + +.PARAMETER Header + Additional header values for the JWT. Defaults to an empty hashtable if not specified. + +.PARAMETER Payload + The required hashtable specifying the token’s claims. + +.PARAMETER Algorithm + A string representing the signing algorithm to be used. Accepts 'NONE', 'HS256', 'HS384', or 'HS512'. + +.PARAMETER Secret + Used in conjunction with HMAC signing. Can be either a byte array or a SecureString. Required if you + select the 'Secret' parameter set. + +.PARAMETER X509Certificate + An X509Certificate2 object used for RSA/ECDSA-based signing. Required if you select the 'CertRaw' parameter set. + +.PARAMETER Certificate + The path to a certificate file used for signing. Required if you select the 'CertFile' parameter set. + +.PARAMETER PrivateKeyPath + Optional path to an associated certificate key file. + +.PARAMETER CertificatePassword + An optional SecureString password for a certificate file. + +.PARAMETER CertificateThumbprint + A string thumbprint of a certificate in the local store. Required if you select the 'CertThumb' parameter set. + +.PARAMETER CertificateName + A string name of a certificate in the local store. Required if you select the 'CertName' parameter set. + +.PARAMETER CertificateStoreName + The store name to search for the specified certificate. Defaults to 'My'. + +.PARAMETER CertificateStoreLocation + The certificate store location for the specified certificate. Defaults to 'CurrentUser'. + +.PARAMETER RsaPaddingScheme + Specifies the RSA padding scheme to use. Accepts 'Pkcs1V15' or 'Pss'. Defaults to 'Pkcs1V15'. + +.PARAMETER Authentication + The name of a configured authentication method in Pode. Required if you select the 'AuthenticationMethod' parameter set. + +.PARAMETER Expiration + Time in seconds until the token expires. Defaults to 3600 (1 hour). + +.PARAMETER NotBefore + Time in seconds to offset the NotBefore claim. Defaults to 0 for immediate use. + +.PARAMETER IssuedAt + Time in seconds to offset the IssuedAt claim. Defaults to 0 for current time. + +.PARAMETER Issuer + Identifies the principal that issued the token. + +.PARAMETER Subject + Identifies the principal that is the subject of the token. + +.PARAMETER Audience + Specifies the recipients that the token is intended for. + +.PARAMETER JwtId + A unique identifier for the token. + +.PARAMETER NoStandardClaims + A switch that, if used, prevents automatically adding iat, nbf, exp, iss, sub, aud, and jti claims. + +.OUTPUTS + System.String + The resulting JWT string. + + +.EXAMPLE + ConvertTo-PodeJwt -Header @{ alg = 'none' } -Payload @{ sub = '123'; name = 'John' } + +.EXAMPLE + ConvertTo-PodeJwt -Header @{ alg = 'HS256' } -Payload @{ sub = '123'; name = 'John' } -Secret 'abc' + +.EXAMPLE + ConvertTo-PodeJwt -Header @{ alg = 'RS256' } -Payload @{ sub = '123' } -PrivateKey (Get-Content "private.pem" -Raw) -Issuer "auth.example.com" -Audience "myapi.example.com" +#> +function ConvertTo-PodeJwt { + [CmdletBinding(DefaultParameterSetName = 'Default')] + [OutputType([string])] + param( + [Parameter()] + [hashtable]$Header = @{}, + + [Parameter(Mandatory = $true)] + [hashtable]$Payload, + + [Parameter(ParameterSetName = 'Default')] + [Parameter(ParameterSetName = 'Secret')] + [ValidateSet('NONE', 'HS256', 'HS384', 'HS512')] + [string]$Algorithm, + + [Parameter(Mandatory = $true, ParameterSetName = 'Secret')] + $Secret = $null, + + [Parameter(Mandatory = $true, ParameterSetName = 'CertRaw')] + [System.Security.Cryptography.X509Certificates.X509Certificate2] + $X509Certificate, + + [Parameter(Mandatory = $true, ParameterSetName = 'CertFile')] + [string]$Certificate, + + [Parameter(Mandatory = $false, ParameterSetName = 'CertFile')] + [string]$PrivateKeyPath = $null, + + [Parameter(Mandatory = $false, ParameterSetName = 'CertFile')] + [SecureString]$CertificatePassword, + + [Parameter(Mandatory = $true, ParameterSetName = 'CertThumb')] + [string]$CertificateThumbprint, + + [Parameter(Mandatory = $true, ParameterSetName = 'CertName')] + [string]$CertificateName, + + [Parameter(ParameterSetName = 'CertName')] + [Parameter(ParameterSetName = 'CertThumb')] + [System.Security.Cryptography.X509Certificates.StoreName] + $CertificateStoreName = 'My', + + [Parameter(ParameterSetName = 'CertName')] + [Parameter(ParameterSetName = 'CertThumb')] + [System.Security.Cryptography.X509Certificates.StoreLocation] + $CertificateStoreLocation = 'CurrentUser', + + [Parameter(Mandatory = $false, ParameterSetName = 'CertRaw')] + [Parameter(Mandatory = $false, ParameterSetName = 'CertFile')] + [Parameter(Mandatory = $false, ParameterSetName = 'CertName')] + [Parameter(Mandatory = $false, ParameterSetName = 'CertThumb')] + [ValidateSet('Pkcs1V15', 'Pss')] + [string] + $RsaPaddingScheme = 'Pkcs1V15', + + [Parameter(Mandatory = $true, ParameterSetName = 'AuthenticationMethod')] + [string] + $Authentication, + + [Parameter()] + [int] + $Expiration = 3600, # Default: 1 hour + + [Parameter()] + [int] + $NotBefore = 0, # Default: Immediate + + [Parameter()] + [int]$IssuedAt = 0, # Default: Current time + + [Parameter()] + [string]$Issuer, + + [Parameter()] + [string]$Subject, + + [Parameter()] + [string]$Audience, + + [Parameter()] + [string]$JwtId, + + [Parameter()] + [switch] + $NoStandardClaims + ) + + $psHeader = [PSCustomObject]$Header + $psPayload = [PSCustomObject]$Payload + # Optionally add standard claims if not suppressed + if (!$NoStandardClaims) { + if (! $psHeader.PSObject.Properties['typ']) { + $psHeader | Add-Member -MemberType NoteProperty -Name 'typ' -Value 'JWT' + } + else { + $psHeader.typ = 'JWT' + } + + # Current Unix time + $currentUnix = [int][Math]::Floor(([DateTimeOffset]::new([DateTime]::UtcNow)).ToUnixTimeSeconds()) + + if (! $psPayload.PSObject.Properties['iat']) { + $psPayload | Add-Member -MemberType NoteProperty -Name 'iat' -Value $(if ($IssuedAt -gt 0) { $IssuedAt } else { $currentUnix }) + } + if (! $psPayload.PSObject.Properties['nbf']) { + $psPayload | Add-Member -MemberType NoteProperty -Name 'nbf' -Value ($currentUnix + $NotBefore) + } + if (! $psPayload.PSObject.Properties['exp']) { + $psPayload | Add-Member -MemberType NoteProperty -Name 'exp' -Value ($currentUnix + $Expiration) + } + + if (! $psPayload.PSObject.Properties['iss']) { + if ([string]::IsNullOrEmpty($Issuer)) { + if ($null -ne $PodeContext) { + $psPayload | Add-Member -MemberType NoteProperty -Name 'iss' -Value 'Pode' + } + } + else { + $psPayload | Add-Member -MemberType NoteProperty -Name 'iss' -Value $Issuer + } + } + + if (! $psPayload.PSObject.Properties['sub'] -and ![string]::IsNullOrEmpty($Subject)) { + $psPayload | Add-Member -MemberType NoteProperty -Name 'sub' -Value $Subject + } + + if (! $psPayload.PSObject.Properties['aud']) { + if ([string]::IsNullOrEmpty($Audience)) { + if (($null -ne $PodeContext) -and ($null -ne $PodeContext.Server.ApplicationName)) { + $psPayload | Add-Member -MemberType NoteProperty -Name 'aud' -Value $PodeContext.Server.ApplicationName + } + } + else { + $psPayload | Add-Member -MemberType NoteProperty -Name 'aud' -Value $Audience + } + } + + if (! $psPayload.PSObject.Properties['jti'] ) { + if ([string]::IsNullOrEmpty($JwtId)) { + $psPayload | Add-Member -MemberType NoteProperty -Name 'jti' -Value (New-PodeGuid) + } + else { + $psPayload | Add-Member -MemberType NoteProperty -Name 'jti' -Value $JwtId + } + } + } + # Determine actions based on parameter set + switch ($PSCmdlet.ParameterSetName) { + 'CertFile' { + return New-PodeJwt -Certificate $Certificate -CertificatePassword $CertificatePassword ` + -PrivateKeyPath $PrivateKeyPath -RsaPaddingScheme $RsaPaddingScheme ` + -Payload $psPayload -Header $psHeader + } + + 'certthumb' { + return New-PodeJwt -CertificateThumbprint $CertificateThumbprint -CertificateStoreName $CertificateStoreName ` + -CertificateStoreLocation $CertificateStoreLocation -RsaPaddingScheme $RsaPaddingScheme ` + -Payload $psPayload -Header $psHeader + } + + 'certname' { + return New-PodeJwt -CertificateName $CertificateName -CertificateStoreName $CertificateStoreName ` + -CertificateStoreLocation $CertificateStoreLocation -RsaPaddingScheme $RsaPaddingScheme ` + -Payload $psPayload -Header $psHeader + } + + 'Secret' { + # Convert secret to a byte array if needed + if (($null -ne $Secret) -and ($Secret -isnot [byte[]])) { + $Secret = if ($Secret -is [SecureString]) { + Convert-PodeSecureStringToByteArray -SecureString $Secret + } + else { + [System.Text.Encoding]::UTF8.GetBytes([string]$Secret) + } + + if ($null -eq $Secret) { + throw ($PodeLocale.missingKeyForAlgorithmExceptionMessage -f 'secret', 'HMAC', $psHeader['alg']) + } + } + + if ([string]::IsNullOrWhiteSpace($Algorithm)) { + $Algorithm = 'HS256' + } + + return New-PodeJwt -Secret $Secret -Algorithm $Algorithm ` + -Payload $psPayload -Header $psHeader + } + + 'CertRaw' { + return New-PodeJwt -X509Certificate $X509Certificate -RsaPaddingScheme $RsaPaddingScheme ` + -Payload $psPayload -Header $psHeader + } + + 'AuthenticationMethod' { + return New-PodeJwt -Authentication $Authentication ` + -Payload $psPayload -Header $psHeader + } + default { + return New-PodeJwt -Algorithm 'None' ` + -Payload $psPayload -Header $psHeader + } + } +} + +<# +.SYNOPSIS + Updates the expiration time of a JWT token. + +.DESCRIPTION + This function updates the expiration time of a given JWT token by extending it with a specified duration. + It supports various signing methods including secret-based and certificate-based signing. + The function can handle different types of certificates and authentication methods for signing the updated token. + +.PARAMETER Token + The JWT token to be updated. + +.PARAMETER ExpirationExtension + The number of seconds to extend the expiration time by. If not specified, the original expiration duration is used. + +.PARAMETER Secret + The secret key used for HMAC signing (string or byte array). + +.PARAMETER X509Certificate + The raw X509 certificate used for RSA or ECDSA signing. + +.PARAMETER Certificate + The path to a certificate file used for signing. + +.PARAMETER CertificatePassword + The password for the certificate file referenced in Certificate. + +.PARAMETER PrivateKeyPath + A key file to be paired with a PEM certificate file referenced in Certificate. + +.PARAMETER CertificateThumbprint + A certificate thumbprint to use for RSA or ECDSA signing. (Windows). + +.PARAMETER CertificateName + A certificate subject name to use for RSA or ECDSA signing. (Windows). + +.PARAMETER CertificateStoreName + The name of a certificate store where a certificate can be found (Default: My) (Windows). + +.PARAMETER CertificateStoreLocation + The location of a certificate store where a certificate can be found (Default: CurrentUser) (Windows). + +.PARAMETER Authentication + The authentication method from Pode's context used for JWT signing. + +.EXAMPLE + Update-PodeJwt -Token "" -ExpirationExtension 3600 -Secret "MySecretKey" + This example updates the expiration time of a JWT token by extending it by 1 hour using an HMAC secret. + +.EXAMPLE + Update-PodeJwt -Token "" -ExpirationExtension 3600 -X509Certificate $Certificate + This example updates the expiration time of a JWT token by extending it by 1 hour using an X509 certificate. +#> +function Update-PodeJwt { + [CmdletBinding(DefaultParameterSetName = 'Default')] + param ( + [Parameter(Mandatory = $true, ParameterSetName = 'Secret')] + [Parameter(Mandatory = $true, ParameterSetName = 'CertName')] + [Parameter(Mandatory = $true, ParameterSetName = 'CertThumb')] + [Parameter(Mandatory = $true, ParameterSetName = 'CertRaw')] + [Parameter(Mandatory = $true, ParameterSetName = 'CertFile')] + [string] + $Token, + + [Parameter()] + [int] + $ExpirationExtension = 0, + + [Parameter(Mandatory = $true, ParameterSetName = 'Secret')] + $Secret = $null, + + [Parameter(Mandatory = $true, ParameterSetName = 'CertRaw')] + [System.Security.Cryptography.X509Certificates.X509Certificate2] + $X509Certificate, + + [Parameter(Mandatory = $true, ParameterSetName = 'CertFile')] + [string]$Certificate, + + [Parameter(Mandatory = $false, ParameterSetName = 'CertFile')] + [string]$PrivateKeyPath = $null, + + [Parameter(Mandatory = $false, ParameterSetName = 'CertFile')] + [SecureString]$CertificatePassword, + + [Parameter(Mandatory = $true, ParameterSetName = 'CertThumb')] + [string]$CertificateThumbprint, + + [Parameter(Mandatory = $true, ParameterSetName = 'CertName')] + [string]$CertificateName, + + [Parameter(ParameterSetName = 'CertName')] + [Parameter(ParameterSetName = 'CertThumb')] + [System.Security.Cryptography.X509Certificates.StoreName] + $CertificateStoreName = 'My', + + [Parameter(ParameterSetName = 'CertName')] + [Parameter(ParameterSetName = 'CertThumb')] + [System.Security.Cryptography.X509Certificates.StoreLocation] + $CertificateStoreLocation = 'CurrentUser', + + [Parameter(Mandatory = $true, ParameterSetName = 'AuthenticationMethod')] + [string] + $Authentication + ) + + $parameterSetName = $PSCmdlet.ParameterSetName + + if ($parameterSetName -eq 'Default') { + if ($null -ne $WebEvent -and $null -ne $WebEvent.Auth.Name) { + $parameterSetName = 'AuthenticationMethod' + $Authentication = $WebEvent.Auth.Name + } + } + + if ($parameterSetName -eq 'AuthenticationMethod') { + $token = Get-PodeBearenToken + } + + $jwt = ConvertFrom-PodeJwt -Token $Token -IgnoreSignature -Outputs 'Header,Payload' + if ($null -eq $jwt.Payload.exp) { + throw ($PodeLocale.jwtNoExpirationExceptionMessage) + } + + if ($ExpirationExtension -eq 0 -and $jwt.Payload.exp -and $jwt.Payload.iat) { + $ExpirationExtension = $jwt.Payload.exp - $jwt.Payload.iat + } + # if the token has an expiration time, update it + if ($ExpirationExtension -gt 0) { + $jwt.Payload.exp = [int][Math]::Floor(([DateTimeOffset]::new([DateTime]::UtcNow)).ToUnixTimeSeconds()) + $ExpirationExtension + } + + if ('PS256', 'PS384', 'PS512' -ccontains $jwt.Header.alg) { + $rsaPaddingScheme = 'Pss' + } + else { + $rsaPaddingScheme = 'Pkcs1V15' + } + + $params = switch ($parameterSetName) { + # If the secret is provided as a byte array, use it for signing + 'CertFile' { + @{ + Payload = $jwt.Payload + Header = $jwt.Header + Certificate = $Certificate + PrivateKeyPath = $PrivateKeyPath + CertificatePassword = $CertificatePassword + RsaPaddingScheme = $rsaPaddingScheme + } + } + # If the certificate thumbprint is provided, use it for signing + 'CertThumb' { + @{ + Payload = $jwt.Payload + Header = $jwt.Header + CertificateThumbprint = $CertificateThumbprint + CertificateStoreName = $CertificateStoreName + CertificateStoreLocation = $CertificateStoreLocation + RsaPaddingScheme = $rsaPaddingScheme + } + } + # If the certificate name is provided, use it for signing + 'CertName' { + @{ + Payload = $jwt.Payload + Header = $jwt.Header + CertificateName = $CertificateName + CertificateStoreName = $CertificateStoreName + CertificateStoreLocation = $CertificateStoreLocation + RsaPaddingScheme = $rsaPaddingScheme + } + } + # If the secret is provided as a byte array, use it for signing + 'Secret' { + @{ + Payload = $jwt.Payload + Header = $jwt.Header + Secret = $Secret + } + } + # If the certificate is provided as a raw object, use it for signing + 'CertRaw' { + @{ + Payload = $jwt.Payload + Header = $jwt.Header + X509Certificate = $X509Certificate + } + } + 'AuthenticationMethod' { + @{ + Payload = $jwt.Payload + Header = $jwt.Header + Authentication = $Authentication + } + } + } + + # Update the JWT with the new expiration time + return New-PodeJwt @params +} \ No newline at end of file diff --git a/src/Public/Routes.ps1 b/src/Public/Routes.ps1 index 575fede7f..d6c2101d8 100644 --- a/src/Public/Routes.ps1 +++ b/src/Public/Routes.ps1 @@ -344,6 +344,10 @@ function Add-PodeRoute { throw ($PodeLocale.authenticationMethodDoesNotExistExceptionMessage -f $Authentication) } + # Validate that the HTTP method supports a request body when using bearer token authentication. + # This ensures that only PUT, POST, and PATCH methods are used for body-based authentication. + Test-PodeBodyAuthMethod -Method $Method -Authentication $Authentication + $options = @{ Name = $Authentication Login = $Login @@ -828,6 +832,10 @@ function Add-PodeStaticRoute { throw ($PodeLocale.authenticationMethodDoesNotExistExceptionMessage) } + # Validate that the HTTP method supports a request body when using bearer token authentication. + # This ensures that only PUT, POST, and PATCH methods are used for body-based authentication. + Test-PodeBodyAuthMethod -Method $Method -Authentication $Authentication + $options = @{ Name = $Authentication Anon = $AllowAnon diff --git a/src/Public/Utilities.ps1 b/src/Public/Utilities.ps1 index e193bbbc3..c11e8ea55 100644 --- a/src/Public/Utilities.ps1 +++ b/src/Public/Utilities.ps1 @@ -1607,7 +1607,4 @@ function Start-PodeSleep { # Sleep for the interval Start-Sleep -Milliseconds $sleepInterval } -} - - - +} \ No newline at end of file diff --git a/tests/integration/Authentication.Tests.ps1 b/tests/integration/Authentication.Tests.ps1 index 67f4147c3..ad4a3a2b4 100644 --- a/tests/integration/Authentication.Tests.ps1 +++ b/tests/integration/Authentication.Tests.ps1 @@ -146,8 +146,8 @@ Describe 'Authentication Requests' { { Invoke-RestMethod -Uri "$($Endpoint)/auth/bearer" -Method Get -Headers @{ Authorization = 'Bearer fake-token' } -ErrorAction Stop } | Should -Throw -ExpectedMessage '*401*' } - It 'bearer - returns 400 for no token' { - { Invoke-RestMethod -Uri "$($Endpoint)/auth/bearer" -Method Get -Headers @{ Authorization = 'Bearer' } -ErrorAction Stop } | Should -Throw -ExpectedMessage '*400*' + It 'bearer - returns 401 for no token' { + { Invoke-RestMethod -Uri "$($Endpoint)/auth/bearer" -Method Get -Headers @{ Authorization = 'Bearer' } -ErrorAction Stop } | Should -Throw -ExpectedMessage '*401*' } diff --git a/tests/integration/DigestAuthentication.Tests.ps1 b/tests/integration/DigestAuthentication.Tests.ps1 new file mode 100644 index 000000000..6b2db38ca --- /dev/null +++ b/tests/integration/DigestAuthentication.Tests.ps1 @@ -0,0 +1,444 @@ +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '')] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseUsingScopeModifierInNewRunspaces', '')] +param() +BeforeAll { + $path = $PSCommandPath + $src = (Split-Path -Parent -Path $path) -ireplace '[\\/]tests[\\/]integration', '/src/' + $CertsPath = (Split-Path -Parent -Path $path) -ireplace '[\\/]tests[\\/]integration', '/tests/certs/' + Get-ChildItem "$($src)/*.ps1" -Recurse | Resolve-Path | ForEach-Object { . $_ } + + # load assemblies + Add-Type -AssemblyName System.Web -ErrorAction Stop + Add-Type -AssemblyName System.Net.Http -ErrorAction Stop + + $module = (Split-Path -Parent -Path $path) -ireplace '[\\/]tests[\\/]integration', '/examples/Authentication/Modules' + Import-Module "$module/Invoke-Digest.psm1" + + function ConvertTo-Hash { + param ( + [string]$Value, + [string]$Algorithm + ) + + $crypto = switch ($Algorithm) { + 'MD5' { [System.Security.Cryptography.MD5]::Create() } + 'SHA-1' { [System.Security.Cryptography.SHA1]::Create() } + 'SHA-256' { [System.Security.Cryptography.SHA256]::Create() } + 'SHA-384' { [System.Security.Cryptography.SHA384]::Create() } + 'SHA-512' { [System.Security.Cryptography.SHA512]::Create() } + 'SHA-512/256' { + # Compute SHA-512 and take first 32 bytes (256 bits) + $sha512 = [System.Security.Cryptography.SHA512]::Create() + $fullHash = $sha512.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($Value)) + return [System.BitConverter]::ToString($fullHash[0..31]).Replace('-', '').ToLowerInvariant() + } + } + + return [System.BitConverter]::ToString($crypto.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($Value))).Replace('-', '').ToLowerInvariant() + } + + function ChallengeDigest { + param( + [Parameter(Mandatory = $true)] + [ValidateSet('Connect', 'Delete', 'Get', 'Head', 'Merge', 'Options', 'Patch', 'Post', 'Put', 'Trace' )] + [string]$Method, + + [Parameter(Mandatory = $true)] + [string]$Uri + + ) + # Create an HTTP client + $handler = [System.Net.Http.HttpClientHandler]::new() + $httpClient = [System.Net.Http.HttpClient]::new($handler) + + # Step 1: Send an initial request to get the challenge + $initialRequest = [System.Net.Http.HttpRequestMessage]::new([System.Net.Http.HttpMethod]::$Method, $Uri) + $initialResponse = $httpClient.SendAsync($initialRequest).Result + if ($null -eq $initialResponse) { + Throw "Server $uri is not responding" + } + + # Extract WWW-Authenticate headers safely + $wwwAuthHeaders = $initialResponse.Headers.GetValues('WWW-Authenticate') + + # Filter to get only the Digest authentication scheme + $wwwAuthHeader = $wwwAuthHeaders | Where-Object { $_ -match '^Digest' } + + # Debug output + Write-Verbose 'Extracted WWW-Authenticate headers:' + $wwwAuthHeaders | ForEach-Object { Write-Verbose " - $_" } + + # Ensure we have a Digest header before continuing + if (! $wwwAuthHeader) { + Throw 'Digest authentication not supported by server!' + } + + ## Extract Digest Authentication challenge values correctly + $challenge = @{} + + # Ensure the header contains "Digest" + if ($wwwAuthHeader -match '^Digest ') { + # Remove "Digest " prefix + $headerContent = $wwwAuthHeader -replace '^Digest ', '' + + Write-Verbose "RAW HEADER: $headerContent" + + # 1) CAPTURE + if ($headerContent -match 'algorithm=((?:SHA-1|SHA-256|SHA-384|SHA-512(?:/256)?|MD5)(?:,\s*(?:SHA-1|SHA-256|SHA-384|SHA-512(?:/256)?|MD5))*)') { + + $algorithms = ($matches[1] -split '\s*,\s*') + Write-Verbose "Supported Algorithms: $algorithms" + $challenge['algorithm'] = $algorithms + } + + # 2) REMOVE + $headerContent = $headerContent -replace 'algorithm=(?:SHA-1|SHA-256|SHA-384|SHA-512(?:/256)?|MD5)(?:,\s*(?:SHA-1|SHA-256|SHA-384|SHA-512(?:/256)?|MD5))*\s*,?', '' + + # 3) CLEAN UP ANY EXTRA COMMAS/WHITESPACE + $headerContent = $headerContent -replace ',\s*,', ',' + $headerContent = $headerContent -replace '^\s*,', '' + + # Now split the rest of the parameters safely + $headerContent -split ', ' | ForEach-Object { + $key, $value = $_ -split '=', 2 + if ($key -and $value) { + $challenge[$key.Trim()] = $value.Trim('"') + } + } + } + + # Output the parsed challenge + Write-Verbose 'Extracted Digest Authentication Challenge:' + $challenge | ForEach-Object { Write-Verbose "$($_.Key) = $($_.Value)" } + + # Display parsed challenge values + Write-Verbose $challenge + + # Extract necessary parameters from the challenge + + $realm = $challenge['realm'] + $nonce = $challenge['nonce'] + $qop = $challenge['qop'] + $algorithm = $challenge['algorithm'] + + # Ensure qop is an array + # $qopOptions = $qop -split '\s*,\s*' + + if (('Post', 'Put', 'Patch') -contains $Method) { + if ($qop -eq 'auth-int' -or $qop -eq 'auth,auth-int') { + $qop = 'auth-int' + } + else { + $qop = 'auth' + } + } + else { + if ($qop -eq 'auth' -or $qop -eq 'auth,auth-int') { + $qop = 'auth' + } + else { + throw "$Method doesn't support QualityOfProtection 'auth-int'" + } + } + + Write-Verbose "Selected QOP: $qop" + + # Define the preferred algorithm order (strongest to weakest) + $preferredAlgorithms = @('SHA-512/256', 'SHA-512', 'SHA-384', 'SHA-256', 'SHA-1', 'MD5') + + # Ensure serverAlgorithms is an array + if ($algorithm -isnot [System.Array]) { + $algorithm = @($algorithm) + } + + # Select the strongest algorithm that both client and server support + $algorithm = ($preferredAlgorithms | Where-Object { $algorithm -contains $_ } | Select-Object -First 1) + + if (-not $algorithm) { + Throw "No supported algorithms found! Server supports: $algorithm" + } + return [PSCustomObject]@{ + realm = $realm + nonce = $nonce + qop = $qop + algorithm = $algorithm + wwwAuthHeader = $wwwAuthHeader + uri = $Uri + httpClient = $httpClient + method = $Method + } + } + + + function ResponseDigest { + param( + [Parameter(Mandatory = $true)] + [psobject]$Challenge, + + [Parameter(Mandatory = $true)] + [string]$Username, + [Parameter(Mandatory = $true)] + [string]$Password, + [hashtable]$Body + + ) + $nc = '00000001' # Nonce Count + $cnonce = (New-Guid).Guid.Substring(0, 8) # Generate a random client nonce + + + $Method = $Challenge.Method.ToUpper() + + Write-Verbose "Using method: $method" + + # Build the URI path + $uriPath = [System.Uri]$Challenge.uri + $uriPath = $uriPath.AbsolutePath # "/users" + + # Compute HA1 + $HA1 = ConvertTo-Hash -Value "$($Username):$($Challenge.realm):$($Password)" -Algorithm $Challenge.algorithm + + # <--- MODIFIED: Handle HA2 for auth-int + if ($Challenge.qop -eq 'auth-int') { + if (('Post', 'Put', 'Patch') -notcontains $Method) { + Throw "'auth-int' doens't support $Method" + } + # Sample request body + $requestBody = $Body | ConvertTo-Json + $entityBodyHash = ConvertTo-Hash -Value $requestBody -Algorithm $Challenge.algorithm + $HA2 = ConvertTo-Hash -Value "$($method):$($uriPath):$($entityBodyHash)" -Algorithm $Challenge.algorithm + + } + else { + # Standard auth + $HA2 = ConvertTo-Hash -Value "$($method):$($uriPath)" -Algorithm $Challenge.algorithm + } + + # Compute final response hash + $response = ConvertTo-Hash -Value "$($HA1):$($Challenge.nonce):$($nc):$($cnonce):$($Challenge.qop):$($HA2)" -Algorithm $Challenge.algorithm + + + + # Step 3: Construct the Authorization header + $authHeader = @" +Digest username="$username", realm="$($Challenge.realm)", nonce="$($Challenge.nonce)", uri="$uriPath", algorithm=$($Challenge.algorithm), response="$response", qop="$($Challenge.qop)", nc=$nc, cnonce="$cnonce" +"@ + + Write-Verbose "Authorization Header: $authHeader" + + # Step 4: Send the authenticated request + $authRequest = [System.Net.Http.HttpRequestMessage]::new([System.Net.Http.HttpMethod]::$method , $Challenge.uri) + $authRequest.Headers.Authorization = [System.Net.Http.Headers.AuthenticationHeaderValue]::new('Digest', $authHeader) + + # <--- MODIFIED: If auth-int, attach the request body + if ($Challenge.qop -eq 'auth-int') { + $authRequest.Content = [System.Net.Http.StringContent]::new($requestBody, [System.Text.Encoding]::UTF8, 'application/json') + } + + $response = $Challenge.httpClient.SendAsync($authRequest).Result + + # Optionally, get content as string if needed + $content = $response.Content.ReadAsStringAsync().Result + + return [PSCustomObject]@{ + # Extract and display the response headers + Header = $response.Headers | ForEach-Object { "$($_.Key): $($_.Value)" } + Content = $content + AuthHeader = $authHeader + } + } + + +} + +Describe 'Digest Authentication Requests' { + + BeforeAll { + + $Port = 8080 + $Endpoint = "http://127.0.0.1:$($Port)" + + Start-Job -Name 'Pode' -ErrorAction Stop -ScriptBlock { + Import-Module -Name "$($using:PSScriptRoot)\..\..\src\Pode.psm1" + + Start-PodeServer -Quiet -ScriptBlock { + Add-PodeEndpoint -Address localhost -Port $using:Port -Protocol Http + + New-PodeLoggingMethod -Terminal | Enable-PodeErrorLogging + Add-PodeRoute -Method Get -Path '/close' -ScriptBlock { + Close-PodeServer + } + + foreach ($alg in ('MD5', 'SHA-1', 'SHA-256', 'SHA-512', 'SHA-384', 'SHA-512/256')) { + foreach ($qop in ('auth', 'auth-int', 'auth,auth-int' )) { + + New-PodeAuthDigestScheme -Algorithm $alg -QualityOfProtection $qop | Add-PodeAuth -Name "digest_$($alg)_$qop" -Sessionless -ScriptBlock { + param($username, $params) + + # here you'd check a real user storage, this is just for example + if ($username -ieq 'morty') { + return @{ + User = @{ + ID = 'M0R7Y302' + Name = 'Morty' + Type = 'Human' + } + Password = 'pickle' + } + } + + return $null + } + + # If QualityOfProtection is 'auth-int' skip GET because it is not supported + if ($qop -ne 'auth-int') { + # GET request to get list of users (since there's no session, authentication will always happen) + Add-PodeRoute -Method Get -Path "/auth/$alg/$qop" -Authentication "digest_$($alg)_$qop" -ErrorContentType 'application/json' -ScriptBlock { + Write-PodeJsonResponse -Value @{ + success = $true + } + } + } + + Add-PodeRoute -Method Post -Path "/auth/$alg/$qop" -Authentication "digest_$($alg)_$qop" -ErrorContentType 'application/json' -ScriptBlock { + if ($WebEvent.data) { + Write-PodeJsonResponse -Value @{success = $true } -StatusCode 200 + } + else { + Write-PodeJsonResponse -Value @{success = $false } -StatusCode 400 + } + } + } + } + } + } + Start-Sleep -Seconds 10 + + } + + AfterAll { + + Receive-Job -Name 'Pode' | Out-Default + Invoke-RestMethod -Uri "$($Endpoint)/close" -Method Get | Out-Null + Get-Job -Name 'Pode' | Remove-Job -Force + } + + + + Describe 'Digest Authentication' { + + Context 'Digest - Algorithm <_> - Path /auth/<_>' -ForEach ('MD5', 'SHA-1', 'SHA-256', 'SHA-512', 'SHA-384', 'SHA-512/256') { + BeforeDiscovery { + $alg_qop = @() + ForEach ($qop in 'auth', 'auth-int', 'auth,auth-int') { + $alg_qop += @{ + qop = $qop + algorithm = $_ + } + } + } + It 'Digest - Method Get - Algorithm: - QOP:' -ForEach $alg_qop { + + #Write-PodeHost "Testing Algorithm: $algorithm with QOP: $qop" + if ($qop -eq 'auth-int') { + { ChallengeDigest -Uri "$($Endpoint)/auth/$algorithm/$qop" -Method Get } | Should -Throw + } + else { + $challenge = ChallengeDigest -Uri "$($Endpoint)/auth/$algorithm/$qop" -Method Get + + # Validate challenge structure + $challenge | Should -Not -BeNullOrEmpty + $challenge | Should -BeOfType 'PSCustomObject' + + $challenge.realm | Should -Be 'User' + $qop.contains( $challenge.qop) | Should -BeTrue + $challenge.algorithm | Should -Be $algorithm + + # Check that nonce matches a hex pattern (example pattern for 32 hex characters) + $challenge.nonce | Should -Match '^[0-9a-f]{32}$' + + # Check that wwwAuthHeader contains the expected error info + $challenge.wwwAuthHeader | Should -Not -BeNullOrEmpty + $challenge.wwwAuthHeader | Should -Match 'error="invalid_request"' + $challenge.wwwAuthHeader | Should -Match 'error_description="No Authorization header found"' + + + + $response = ResponseDigest -Challenge $challenge -Username 'morty' -Password 'pickle' + # Validate challenge structure + $response | Should -Not -BeNullOrEmpty + $response | Should -BeOfType 'PSCustomObject' + $response.Content | Should -Be '{"success":true}' + + } + } + It 'Digest - Method Post - Algorithm: - QOP:' -ForEach $alg_qop { + + $challenge = ChallengeDigest -Uri "$($Endpoint)/auth/$algorithm/$qop" -Method Post + + # Validate challenge structure + $challenge | Should -Not -BeNullOrEmpty + $challenge | Should -BeOfType 'PSCustomObject' + + $challenge.realm | Should -Be 'User' + $qop.contains( $challenge.qop) | Should -BeTrue + $challenge.algorithm | Should -Be $algorithm + + # Check that nonce matches a hex pattern (example pattern for 32 hex characters) + $challenge.nonce | Should -Match '^[0-9a-f]{32}$' + + # Check that wwwAuthHeader contains the expected error info + $challenge.wwwAuthHeader | Should -Not -BeNullOrEmpty + $challenge.wwwAuthHeader | Should -Match 'error="invalid_request"' + $challenge.wwwAuthHeader | Should -Match 'error_description="No Authorization header found"' + + $response = ResponseDigest -Challenge $challenge -Username 'morty' -Password 'pickle' -body @{message = 'test message' } + # Validate challenge structure + $response | Should -Not -BeNullOrEmpty + $response | Should -BeOfType 'PSCustomObject' + $response.Content | Should -Be '{"success":true}' + + } + + } + + Context 'Invoke-Digest module - Algorithm <_> - Path /auth/<_>' -ForEach ('MD5', 'SHA-1', 'SHA-256', 'SHA-512', 'SHA-384', 'SHA-512/256') -Tag 'Exclude_DesktopEdition' { + BeforeDiscovery { + $alg_qop = @() + ForEach ($qop in 'auth', 'auth-int', 'auth,auth-int') { + $alg_qop += @{ + qop = $qop + algorithm = $_ + } + } + } + BeforeAll { + $username = 'morty' + $password = 'pickle' + + $securePassword = ConvertTo-SecureString $password -AsPlainText -Force + $credential = [System.Management.Automation.PSCredential]::new($username, $securePassword) + } + + It 'Digest - Method Get - Algorithm: - QOP:' -ForEach $alg_qop { + + #Write-PodeHost "Testing Algorithm: $algorithm with QOP: $qop" + if ($qop -eq 'auth-int') { + { Invoke-RestMethodDigest -Uri "$($Endpoint)/auth/$algorithm/$qop" -Method Get -Credential $credential } | Should -Throw + } + else { + $response = Invoke-RestMethodDigest -Uri "$($Endpoint)/auth/$algorithm/$qop" -Method Get -Credential $credential + # Validate challenge structure + $response | Should -Not -BeNullOrEmpty + $response | Should -BeOfType 'PSCustomObject' + $response.success | Should -BeTrue + + } + } + } + } +} + + + + + diff --git a/tests/integration/JWTAuthentication.Tests.ps1 b/tests/integration/JWTAuthentication.Tests.ps1 new file mode 100644 index 000000000..2094a493b --- /dev/null +++ b/tests/integration/JWTAuthentication.Tests.ps1 @@ -0,0 +1,600 @@ +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '')] +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseUsingScopeModifierInNewRunspaces', '')] +param() +BeforeAll { + $path = $PSCommandPath + $src = (Split-Path -Parent -Path $path) -ireplace '[\\/]tests[\\/]integration', '/src/' + $CertsPath = (Split-Path -Parent -Path $path) -ireplace '[\\/]tests[\\/]integration', '/tests/certs/' + Get-ChildItem "$($src)/*.ps1" -Recurse | Resolve-Path | ForEach-Object { . $_ } +} + +Describe 'JWT Bearer Authentication Requests' { + + BeforeAll { + $Port = 8080 + $Endpoint = "http://127.0.0.1:$($Port)" + $secret = (ConvertTo-SecureString 'MySecretKey' -AsPlainText -Force) + $applicationName = 'JWTAuthentication' + + Start-Job -Name 'Pode' -ErrorAction Stop -ScriptBlock { + Import-Module -Name "$($using:PSScriptRoot)\..\..\src\Pode.psm1" + + Start-PodeServer -Quiet -ApplicationName $using:applicationName -ScriptBlock { + Add-PodeEndpoint -Address localhost -Port $using:Port -Protocol Http + + New-PodeLoggingMethod -Terminal | Enable-PodeErrorLogging + Add-PodeRoute -Method Get -Path '/close' -ScriptBlock { + Close-PodeServer + } + + foreach ($alg in ('HS256', 'HS384', 'HS512')) { + New-PodeAuthBearerScheme -AsJWT -Secret $using:secret -Algorithm $alg -JwtVerificationMode Strict | Add-PodeAuth -Name "Bearer_JWT_Secret_strict_$alg" -Sessionless -ScriptBlock { + param($jwt) + + # here you'd check a real user storage, this is just for example + if ($jwt.username -ieq 'morty') { + return @{ + User = @{ + ID = $jWt.id + Name = $jst.name + Type = $jst.type + } + } + } + + return $null + } + + Add-PodeRoute -Method Get -Path "/auth/bearer/jwt/secret/strict/$alg" -Authentication "Bearer_JWT_Secret_strict_$alg" -ScriptBlock { + Write-PodeJsonResponse -Value @{ Result = 'OK' } + } + + New-PodeAuthBearerScheme -AsJWT -Secret $using:secret -Algorithm $alg -JwtVerificationMode Lenient | Add-PodeAuth -Name "Bearer_JWT_Secret_lenient_$alg" -Sessionless -ScriptBlock { + param($jwt) + + # here you'd check a real user storage, this is just for example + if ($jwt.username -ieq 'morty') { + return @{ + User = @{ + ID = $jWt.id + Name = $jst.name + Type = $jst.type + } + } + } + + return $null + } + + Add-PodeRoute -Method Get -Path "/auth/bearer/jwt/secret/lenient/$alg" -Authentication "Bearer_JWT_Secret_lenient_$alg" -ScriptBlock { + Write-PodeJsonResponse -Value @{ Result = 'OK' } + } + } + if (!(Test-Path -Path $using:CertsPath -PathType Container)) { + New-Item -Path $using:CertsPath -ItemType Directory + } + + # $securePassword = ConvertTo-SecureString 'MySecurePassword' -AsPlainText -Force + + $certificateTypes = @{ + 'RS256' = @{ + KeyType = 'RSA' + KeyLength = 2048 + RsaPaddingScheme = 'Pkcs1V15' + } + 'RS384' = @{ + KeyType = 'RSA' + KeyLength = 3072 + RsaPaddingScheme = 'Pkcs1V15' + } + 'RS512' = @{ + KeyType = 'RSA' + KeyLength = 4096 + RsaPaddingScheme = 'Pkcs1V15' + } + 'PS256' = @{ + KeyType = 'RSA' + KeyLength = 2048 + RsaPaddingScheme = 'Pss' + } + 'PS384' = @{ + KeyType = 'RSA' + KeyLength = 3072 + RsaPaddingScheme = 'Pss' + } + 'PS512' = @{ + KeyType = 'RSA' + KeyLength = 4096 + RsaPaddingScheme = 'Pss' + } + 'ES256' = @{ + KeyType = 'ECDSA' + KeyLength = 256 + } + 'ES384' = @{ + KeyType = 'ECDSA' + KeyLength = 384 + } + 'ES512' = @{ + KeyType = 'ECDSA' + KeyLength = 521 + } + } + foreach ($alg in $certificateTypes.Keys) { + $x509Certificate = New-PodeSelfSignedCertificate -Loopback -KeyType $certificateTypes[$alg].KeyType -KeyLength $certificateTypes[$alg].KeyLength -CertificatePurpose CodeSigning -Ephemeral -Exportable + + Export-PodeCertificate -Certificate $x509Certificate -Format PFX -Path (join-path -path $using:CertsPath -ChildPath $alg) | Out-File -FilePath "$using:CertsPath/a.txt" -Append + + $rsaPaddingScheme = if ($alg.StartsWith('PS')) { 'Pss' } else { 'Pkcs1V15' } + + + + # Define the authentication location dynamically (e.g., `/auth/bearer/jwt/{algorithm}`) + $pathRoute = "/auth/bearer/jwt/key/lenient/$alg" + # Register Pode Bearer Authentication + $param = @{ + AsJWT = $true + RsaPaddingScheme = $rsaPaddingScheme + JwtVerificationMode = 'Lenient' + X509Certificate = $x509Certificate + # CertificatePassword = $securePassword + } + + New-PodeAuthBearerScheme @param | + Add-PodeAuth -Name "Bearer_JWT_lenient_$alg" -Sessionless -ScriptBlock { + param($jwt) + + # here you'd check a real user storage, this is just for example + if ($jwt.username -ieq 'morty') { + return @{ + User = @{ + ID = $jWt.id + Name = $jst.name + Type = $jst.type + } + } + } + + return $null + } + + # GET request to get list of users (since there's no session, authentication will always happen) + Add-PodeRoute -Method Get -Path "/auth/bearer/jwt/key/lenient/$alg" -Authentication "Bearer_JWT_lenient_$alg" -ScriptBlock { + Write-PodeJsonResponse -Value @{ Result = 'OK' } + } + + $param.JwtVerificationMode = 'Strict' + New-PodeAuthBearerScheme @param | + Add-PodeAuth -Name "Bearer_JWT_strict_$alg" -Sessionless -ScriptBlock { + param($jwt) + + # here you'd check a real user storage, this is just for example + if ($jwt.username -ieq 'morty') { + return @{ + User = @{ + ID = $jWt.id + Name = $jst.name + Type = $jst.type + } + } + } + + return $null + } + + # GET request to get list of users (since there's no session, authentication will always happen) + Add-PodeRoute -Method Get -Path "/auth/bearer/jwt/key/strict/$alg" -Authentication "Bearer_JWT_strict_$alg" -ScriptBlock { + Write-PodeJsonResponse -Value @{ Result = 'OK' } + } + } + + + #lifecycle + + # Register Pode Bearer Authentication + New-PodeAuthBearerScheme -AsJWT -JwtVerificationMode Strict -SelfSigned | + Add-PodeAuth -Name 'Bearer_JWT_SelfSigned' -Sessionless -ScriptBlock { + param($jwt) + + # here you'd check a real user storage, this is just for example + if ($jwt.id -ieq 'M0R7Y302') { + return @{ + User = @{ + ID = $jWt.id + Name = $jWt.name + Type = $jWt.type + sub = $jWt.Id + username = $jWt.Username + groups = $jWt.Groups + } + } + } + else { + write-podehost $jwt -Explode + } + + return $null + } + + + Add-PodeRoute -Method Post -Path '/auth/bearer/jwt/login' -ScriptBlock { + try { + # In a real scenario, you'd validate the incoming credentials from $WebEvent.data + $username = $WebEvent.Data.username + $password = $WebEvent.Data.password + $user = if ($username -eq 'morty' -and $password -eq 'pickle') { + @{ + Id = 'M0R7Y302' + Username = 'morty.smith' + Name = 'Morty Smith' + Groups = 'Domain Users' + } + } + if (!$user) { + throw 'Invalid credentials' + } + $payload = @{ + sub = $user.Id + name = $user.Name + username = $user.Username + id = $user.Id + groups = $user.Groups + type = 'human' + } + + # If valid, generate a JWT that matches the 'ExampleApiKeyCert' scheme + $jwt = ConvertTo-PodeJwt -Payload $payload -Authentication 'Bearer_JWT_SelfSigned' -Expiration 600 + Write-PodeJsonResponse -StatusCode 200 -Value @{ + 'success' = $true + 'user' = $user + 'token' = $jwt + } + + } + catch { + write-podehost $_.Exception.Message + Write-PodeJsonResponse -StatusCode 401 -Value @{ error = 'Invalid credentials' } + } + } + + Add-PodeRoute -Method Post -Path '/auth/bearer/jwt/renew' -Authentication 'Bearer_JWT_SelfSigned' -ScriptBlock { + try { + + $jwt = Update-PodeJwt -ExpirationExtension 6000 + + Write-PodeJsonResponse -StatusCode 200 -Value @{ + 'success' = $true + 'token' = $jwt + } + } + catch { + Write-PodeJsonResponse -StatusCode 401 -Value @{ error = 'Invalid JWT token supplied' } + } + } + + Add-PodeRoute -Method Get -Path '/auth/bearer/jwt/info' -Authentication 'Bearer_JWT_SelfSigned' -ScriptBlock { + try { + $jwtInfo = ConvertFrom-PodeJwt -Outputs 'Header,Payload,Signature' -HumanReadable + $jwtInfo.success = $true + Write-PodeJsonResponse -StatusCode 200 -Value $jwtInfo + } + catch { + Write-PodeJsonResponse -StatusCode 401 -Value @{ error = 'Invalid JWT token supplied' } + } + } + + } + } + + Start-Sleep -Seconds 20 + } + + AfterAll { + + Receive-Job -Name 'Pode' | Out-Default + Invoke-RestMethod -Uri "$($Endpoint)/close" -Method Get | Out-Null + Get-Job -Name 'Pode' | Remove-Job -Force + if ( (Test-Path -Path $CertsPath -PathType Container)) { + Remove-Item -Path $CertsPath -Recurse -Force + Write-Output "$CertsPath removed." + } + } + + + + Describe 'Bearer Authentication - JWT Algorithms' { + + Context 'Bearer - Algorithm <_> - Lenient - Path /auth/bearer/jwt/key/<_>' -ForEach (('RS256', 'RS384', 'RS512', 'PS256', 'PS384', 'PS512', 'ES256', 'ES384', 'ES512')) { + It "Bearer - Algorithm $_ - returns OK for valid key" { + + # Define corresponding private key path + $privateKeyPath = "$CertsPath/$_.pfx" + # Ensure the matching private key exists + (Test-Path $privateKeyPath) | Should -BeTrue + + $rsaPaddingScheme = if ($_.StartsWith('PS')) { 'Pss' } else { 'Pkcs1V15' } + + # Read key contents + $payload = @{ sub = '123'; username = 'morty' } + $jwt = ConvertTo-PodeJwt -Certificate $privateKeyPath -RsaPaddingScheme $rsaPaddingScheme -Payload $payload + $headers = @{ 'Authorization' = "Bearer $jwt"; 'Accept' = 'application/json' } + + # Make request to correct algorithm path + $result = Invoke-RestMethod -Uri "$($Endpoint)/auth/bearer/jwt/key/lenient/$_" -Method Get -Headers $headers + $result.Result | Should -Be 'OK' + } + } + + Context 'Bearer - Algorithm <_> - Strict - Path /auth/bearer/jwt/key/strict<_>' -ForEach (('RS256', 'RS384', 'RS512', 'PS256', 'PS384', 'PS512', 'ES256', 'ES384', 'ES512')) { + It "Bearer - Algorithm $_ - returns OK for valid key" { + # Define corresponding private key path + $privateKeyPath = "$CertsPath/$_.pfx" + # Ensure the matching private key exists + (Test-Path $privateKeyPath) | Should -BeTrue + + $rsaPaddingScheme = if ($_.StartsWith('PS')) { 'Pss' } else { 'Pkcs1V15' } + + $payload = @{ sub = '123'; username = 'morty' } + $params = @{ + Payload = $payload + Certificate = $privateKeyPath + RsaPaddingScheme = $rsaPaddingScheme + Issuer = 'Pode' + Audience = $applicationName + } + $jwt = ConvertTo-PodeJwt @params + $headers = @{ 'Authorization' = "Bearer $jwt"; 'Accept' = 'application/json' } + + # Make request to correct algorithm path + $result = Invoke-RestMethod -Uri "$($Endpoint)/auth/bearer/jwt/key/strict/$_" -Method Get -Headers $headers + $result.Result | Should -Be 'OK' + } + } + } + + Describe 'Bearer - Algorithm <_> - Lenient - Path /auth/bearer/jwt/secret/lenient/<_>' -ForEach ('HS256', 'HS384', 'HS512') { + It "Bearer - Algorithm $_ - returns OK for valid key" { + $payload = @{ sub = '123'; username = 'morty' } + $jwt = ConvertTo-PodeJwt -Payload $payload -Algorithm $_ -Secret $secret + $headers = @{ 'Authorization' = "Bearer $jwt"; 'Accept' = 'application/json' } + $result = Invoke-RestMethod -Uri "$($Endpoint)/auth/bearer/jwt/secret/lenient/$_" -Method Get -Headers $headers + $result.Result | Should -Be 'OK' + } + + + It 'Bearer - Algorithm <_> - returns OK without issuer in lenient mode' { + $payload = @{ sub = '123'; username = 'morty'; aud = $applicationName } # Missing 'iss' + $jwt = ConvertTo-PodeJwt -Payload $payload -Algorithm $_ -Secret $secret + $headers = @{ 'Authorization' = "Bearer $jwt"; 'Accept' = 'application/json' } + $result = Invoke-RestMethod -Uri "$($Endpoint)/auth/bearer/jwt/secret/lenient/$_" -Method Get -Headers $headers + $result.Result | Should -Be 'OK' + } + + It 'Bearer - Algorithm <_> - returns OK without audience in lenient mode' { + $payload = @{ sub = '123'; username = 'morty'; iss = 'Pode' } # Missing 'aud' + $jwt = ConvertTo-PodeJwt -Payload $payload -Algorithm $_ -Secret $secret -Issuer 'Pode' + $headers = @{ 'Authorization' = "Bearer $jwt"; 'Accept' = 'application/json' } + $result = Invoke-RestMethod -Uri "$($Endpoint)/auth/bearer/jwt/secret/lenient/$_" -Method Get -Headers $headers + $result.Result | Should -Be 'OK' + } + + It 'Bearer - Algorithm <_> - returns OK with incorrect issuer' { + $payload = @{ sub = '123'; username = 'morty'; iss = 'FakeIssuer'; aud = $applicationName } + $jwt = ConvertTo-PodeJwt -Payload $payload -Algorithm $_ -Secret $secret -Issuer 'FakeIssuer' -Audience $applicationName + $headers = @{ 'Authorization' = "Bearer $jwt"; 'Accept' = 'application/json' } + $result = Invoke-RestMethod -Uri "$($Endpoint)/auth/bearer/jwt/secret/lenient/$_" -Method Get -Headers $headers + $result.Result | Should -Be 'OK' + } + + It 'Bearer - Algorithm <_> - returns OK with incorrect audience' { + $payload = @{ sub = '123'; username = 'morty'; iss = 'Pode'; aud = 'WrongApp' } + $jwt = ConvertTo-PodeJwt -Payload $payload -Algorithm $_ -Secret $secret -Issuer 'Pode' -Audience 'WrongApp' + $headers = @{ 'Authorization' = "Bearer $jwt"; 'Accept' = 'application/json' } + $result = Invoke-RestMethod -Uri "$($Endpoint)/auth/bearer/jwt/secret/lenient/$_" -Method Get -Headers $headers + $result.Result | Should -Be 'OK' + } + + } + + # Strict mode for HS256 + Describe 'Bearer - Algorithm <_> - Strict - Path /auth/bearer/jwt/secret/strict/<_>' -ForEach ('HS256', 'HS384', 'HS512') { + It "Bearer - Algorithm $_ - returns OK for valid key" { + $payload = @{ sub = '123'; username = 'morty' } + $jwt = ConvertTo-PodeJwt -Payload $payload -Algorithm $_ -Secret $secret -Issuer 'Pode' -Audience $applicationName + $headers = @{ 'Authorization' = "Bearer $jwt"; 'Accept' = 'application/json' } + $result = Invoke-RestMethod -Uri "$($Endpoint)/auth/bearer/jwt/secret/strict/$_" -Method Get -Headers $headers + $result.Result | Should -Be 'OK' + } + + It 'Bearer - Algorithm <_> - returns 401 for invalid algorithm' { + foreach ($invalidAlg in ('HS256', 'HS384', 'HS512')) { + if ($invalidAlg -eq $_) { continue } + $payload = @{ sub = '123'; username = 'morty' } + $jwt = ConvertTo-PodeJwt -Payload $payload -Algorithm $invalidAlg -Secret $secret -Issuer 'Pode' -Audience $applicationName + $headers = @{ 'Authorization' = "Bearer $jwt"; 'Accept' = 'application/json' } + { Invoke-RestMethod -Uri "$($Endpoint)/auth/bearer/jwt/secret/strict/$_" -Method Get -Headers $headers -ErrorAction Stop } | Should -Throw -ExpectedMessage '*401*' + } + } + + It 'Bearer - Algorithm <_> - rejects token without issuer in strict mode' { + $payload = @{ sub = '123'; username = 'morty'; aud = $applicationName } # Missing 'iss' + $jwt = ConvertTo-PodeJwt -Payload $payload -Algorithm $_ -Secret $secret + $headers = @{ 'Authorization' = "Bearer $jwt"; 'Accept' = 'application/json' } + { Invoke-RestMethod -Uri "$($Endpoint)/auth/bearer/jwt/secret/strict/$_" -Method Get -Headers $headers -ErrorAction Stop } | Should -Throw -ExpectedMessage '*401*' + } + + It 'Bearer - Algorithm <_> - rejects token without audience in strict mode' { + $payload = @{ sub = '123'; username = 'morty'; iss = 'Pode' } # Missing 'aud' + $jwt = ConvertTo-PodeJwt -Payload $payload -Algorithm $_ -Secret $secret -Issuer 'Pode' + $headers = @{ 'Authorization' = "Bearer $jwt"; 'Accept' = 'application/json' } + { Invoke-RestMethod -Uri "$($Endpoint)/auth/bearer/jwt/secret/strict/$_" -Method Get -Headers $headers -ErrorAction Stop } | Should -Throw -ExpectedMessage '*401*' + } + + It 'Bearer - Algorithm <_> - rejects token with incorrect issuer' { + $payload = @{ sub = '123'; username = 'morty'; iss = 'FakeIssuer'; aud = $applicationName } + $jwt = ConvertTo-PodeJwt -Payload $payload -Algorithm $_ -Secret $secret -Issuer 'FakeIssuer' -Audience $applicationName + $headers = @{ 'Authorization' = "Bearer $jwt"; 'Accept' = 'application/json' } + { Invoke-RestMethod -Uri "$($Endpoint)/auth/bearer/jwt/secret/strict/$_" -Method Get -Headers $headers -ErrorAction Stop } | Should -Throw -ExpectedMessage '*401*' + } + + It 'Bearer - Algorithm <_> - rejects token with incorrect audience' { + $payload = @{ sub = '123'; username = 'morty'; iss = 'Pode'; aud = 'WrongApp' } + $jwt = ConvertTo-PodeJwt -Payload $payload -Algorithm $_ -Secret $secret -Issuer 'Pode' -Audience 'WrongApp' + $headers = @{ 'Authorization' = "Bearer $jwt"; 'Accept' = 'application/json' } + { Invoke-RestMethod -Uri "$($Endpoint)/auth/bearer/jwt/secret/strict/$_" -Method Get -Headers $headers -ErrorAction Stop } | Should -Throw -ExpectedMessage '*401*' + } + } + + Describe 'JWT Authentication Workflow' { + BeforeAll { + $Headers = @{ + 'accept' = 'application/json' + 'Content-Type' = 'application/json' + } + } + + It 'Logs in and retrieves a JWT token' { + $Body = @{ + username = 'morty' + password = 'pickle' + } | ConvertTo-Json -Depth 10 + + $Response = Invoke-RestMethod -Uri "$($Endpoint)/auth/bearer/jwt/login" ` + -Method Post ` + -Headers $Headers ` + -Body $Body + + # Validate response + $Response | Should -Not -BeNullOrEmpty + $Response | Should -BeOfType 'PSCustomObject' + $Response.success | Should -Be $true + + # Validate JWT token format + $Response.token | Should -Not -BeNullOrEmpty + $Response.token | Should -Match '^[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+$' + + # Validate user details + $Response.User | Should -Not -BeNullOrEmpty + $Response.User.Username | Should -Be 'morty.smith' + $Response.User.Groups | Should -Be 'Domain Users' + $Response.User.Name | Should -Be 'Morty Smith' + $Response.User.Id | Should -Be 'M0R7Y302' + + # Store JWT for subsequent tests + $script:JwtToken = $Response.token + } + + It 'Validates JWT Token Structure and Claims' { + $Response = Invoke-RestMethod -Uri "$($Endpoint)/auth/bearer/jwt/info" ` + -Method Get ` + -Headers @{ + 'accept' = 'application/json' + 'Authorization' = "Bearer $($script:JwtToken)" + } + + # Validate response structure + $Response | Should -Not -BeNullOrEmpty + $Response | Should -BeOfType 'PSCustomObject' + + # Validate JWT Header + $Response.Header | Should -Not -BeNullOrEmpty + $Response.Header.typ | Should -Be 'JWT' + $Response.Header.alg | Should -Be 'ES384' + + # Validate JWT Payload + $Response.Payload | Should -Not -BeNullOrEmpty + $Response.Payload.type | Should -Be 'human' + $Response.Payload.username | Should -Be 'morty.smith' + $Response.Payload.sub | Should -Be 'M0R7Y302' + $Response.Payload.groups | Should -Be 'Domain Users' + $Response.Payload.name | Should -Be 'Morty Smith' + $Response.Payload.id | Should -Be 'M0R7Y302' + + # Validate JWT Timestamps + $Response.Payload.iat | Should -BeOfType 'datetime' + $Response.Payload.nbf | Should -BeOfType 'datetime' + $Response.Payload.exp | Should -BeOfType 'datetime' + $Response.Payload.iss | Should -Be 'Pode' + $Response.Payload.aud | Should -Be 'JWTAuthentication' + $Response.Payload.jti | Should -Match '^[0-9a-f\-]+$' + + # Validate JWT Signature + $Response.Signature | Should -Not -BeNullOrEmpty + $Response.Signature | Should -Match '^[A-Za-z0-9_\-]+$' + + # Store expiration for comparison + $script:JwtExpiration = $Response.Payload.exp + } + + It 'Renews JWT Token Successfully' { + $Response = Invoke-RestMethod -Uri "$($Endpoint)/auth/bearer/jwt/renew" ` + -Method Post ` + -Headers @{ + 'accept' = 'application/json' + 'Authorization' = "Bearer $($script:JwtToken)" + } ` + -Body '' + + # Validate response structure + $Response | Should -Not -BeNullOrEmpty + $Response | Should -BeOfType 'PSCustomObject' + $Response.success | Should -Be $true + + # Validate JWT token format + $Response.token | Should -Not -BeNullOrEmpty + $Response.token | Should -Match '^[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+\.[A-Za-z0-9-_]+$' + + # Store previous token for comparison + $script:PreviousJwtToken = $script:JwtToken + $script:JwtToken = $Response.token + } + + It 'Validates Renewed JWT Token and Claims' { + $Response = Invoke-RestMethod -Uri "$($Endpoint)/auth/bearer/jwt/info" ` + -Method Get ` + -Headers @{ + 'accept' = 'application/json' + 'Authorization' = "Bearer $($script:JwtToken)" + } + + # Validate response structure + $Response | Should -Not -BeNullOrEmpty + $Response | Should -BeOfType 'PSCustomObject' + $Response.success | Should -Be $true + + # Validate JWT Header + $Response.Header | Should -Not -BeNullOrEmpty + $Response.Header.typ | Should -Be 'JWT' + $Response.Header.alg | Should -Be 'ES384' + + # Validate JWT Payload + $Response.Payload | Should -Not -BeNullOrEmpty + $Response.Payload.type | Should -Be 'human' + $Response.Payload.username | Should -Be 'morty.smith' + $Response.Payload.sub | Should -Be 'M0R7Y302' + $Response.Payload.groups | Should -Be 'Domain Users' + $Response.Payload.name | Should -Be 'Morty Smith' + $Response.Payload.id | Should -Be 'M0R7Y302' + + # Validate JWT Timestamps + $Response.Payload.iat | Should -BeOfType 'datetime' + $Response.Payload.nbf | Should -BeOfType 'datetime' + $Response.Payload.exp | Should -BeOfType 'datetime' + $Response.Payload.iss | Should -Be 'Pode' + $Response.Payload.aud | Should -Be 'JWTAuthentication' + $Response.Payload.jti | Should -Match '^[0-9a-f\-]+$' + + # Validate JWT Signature + $Response.Signature | Should -Not -BeNullOrEmpty + $Response.Signature | Should -Match '^[A-Za-z0-9_\-]+$' + + # Ensure the new token is different from the previous one + $script:JwtToken | Should -Not -BeExactly $script:PreviousJwtToken + + # Validate expiration time increased + $Response.Payload.exp | Should -BeGreaterThan $script:JwtExpiration + } + } + + +} \ No newline at end of file diff --git a/tests/shared/TestHelper.ps1 b/tests/shared/TestHelper.ps1 index 38ccacbe2..5dd3474b6 100644 --- a/tests/shared/TestHelper.ps1 +++ b/tests/shared/TestHelper.ps1 @@ -47,3 +47,5 @@ function Import-PodeAssembly { Add-Type -LiteralPath (Join-Path -Path $netFolder -ChildPath 'Pode.dll') -ErrorAction Stop } } + + diff --git a/tests/unit/Certificate.Tests.ps1 b/tests/unit/Certificate.Tests.ps1 new file mode 100644 index 000000000..8a61491f0 --- /dev/null +++ b/tests/unit/Certificate.Tests.ps1 @@ -0,0 +1,381 @@ +using namespace System.Security.Cryptography + +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '')] +param() +BeforeAll { + $path = $PSCommandPath + $src = (Split-Path -Parent -Path $path) -ireplace '[\\/]tests[\\/]unit', '/src/' + Get-ChildItem "$($src)/*.ps1" -Recurse | Resolve-Path | ForEach-Object { . $_ } + Import-LocalizedData -BindingVariable PodeLocale -BaseDirectory (Join-Path -Path $src -ChildPath 'Locales') -FileName 'Pode' +} + +Describe 'New-PodeCertificateRequest Function' { + + BeforeAll { + # Create a temporary directory for output files. + $tempOutput = Join-Path ([System.IO.Path]::GetTempPath()) ([System.Guid]::NewGuid().ToString()) + New-Item -Path $tempOutput -ItemType Directory | Out-Null + } + + AfterAll { + # Clean up the temporary directory after tests. + Remove-Item $tempOutput -Recurse -Force -ErrorAction SilentlyContinue + } + + BeforeEach { + # Override the internal function with a dummy implementation. + function New-PodeCertificateRequestInternal { + param ( + $DnsName, $CommonName, $Organization, $Locality, $State, $Country, + $KeyType, $KeyLength, $EnhancedKeyUsages, $NotBefore, $CustomExtensions, $FriendlyName + ) + + # Create a dummy private key object with a script method. + $privateKey = [PSCustomObject]@{} + $privateKey | Add-Member -MemberType ScriptMethod -Name ExportPkcs8PrivateKey -Value { + return [System.Text.Encoding]::UTF8.GetBytes('dummykey') + } + + return [PSCustomObject]@{ + Request = 'Dummy CSR Content' + PrivateKey = $privateKey + } + } + } + + It 'Generates a CSR and Private Key and saves them to the specified OutputPath' { + # Define test input values. + $dnsName = 'test.example.com' + $commonName = 'test.example.com' + $org = 'Test Organization' + $locality = 'Test City' + $state = 'Test State' + $country = 'US' + $keyType = 'RSA' + $keyLength = 2048 + + # Call the function. + $result = New-PodeCertificateRequest ` + -DnsName $dnsName ` + -CommonName $commonName ` + -Organization $org ` + -Locality $locality ` + -State $state ` + -Country $country ` + -KeyType $keyType ` + -KeyLength $keyLength ` + -OutputPath $tempOutput + + # Expected file paths. + $expectedCsrPath = Join-Path $tempOutput "$commonName.csr" + $expectedKeyPath = Join-Path $tempOutput "$commonName.key" + + # Validate the returned object. + $result | Should -BeOfType 'PSCustomObject' + $result.CsrPath | Should -Be $expectedCsrPath + $result.PrivateKeyPath | Should -Be $expectedKeyPath + + # Verify that the files have been created. + (Test-Path $result.CsrPath) | Should -BeTrue + (Test-Path $result.PrivateKeyPath) | Should -BeTrue + + # Validate file contents. + $csrContent = Get-Content -Path $result.CsrPath -Raw + $csrContent.Trim() | Should -Be 'Dummy CSR Content' + + $keyContent = Get-Content -Path $result.PrivateKeyPath -Raw + $keyContent | Should -Match '-----BEGIN PRIVATE KEY-----' + $keyContent | Should -Match '-----END PRIVATE KEY-----' + $keyContent | Should -Match 'ZHVtbXlrZXk=' + } +} + + +Describe 'New-PodeSelfSignedCertificate Function' { + + + It 'Generates a valid self-signed certificate with specified parameters' { + # Define test parameters. + $dnsName = @('test.example.com') + $commonName = 'test.example.com' + $org = 'TestOrg' + $locality = 'TestCity' + $state = 'TestState' + $country = 'US' + $keyType = 'RSA' + $keyLength = 2048 + $purpose = 'ServerAuth' + $notBefore = (Get-Date).ToUniversalTime() + $script:friendlyName = 'MyTestCertificate' + $validityDays = 365 + + # Optionally, supply a secure string password for PFX protection. + $script:dummyPassword = ConvertTo-SecureString 'TestPassword' -AsPlainText -Force + + # Call the certificate function. + $script:dummyCert = New-PodeSelfSignedCertificate -DnsName $dnsName ` + -Organization $org -Locality $locality -State $state -Country $country ` + -KeyType $keyType -KeyLength $keyLength -CertificatePurpose $purpose ` + -NotBefore $notBefore -FriendlyName $script:friendlyName -ValidityDays $validityDays ` + -Password $script:dummyPassword -Exportable + + # Validate that a certificate is returned. + $script:dummyCert | Should -BeOfType 'System.Security.Cryptography.X509Certificates.X509Certificate2' + + # Validate the certificate's subject contains the common name. + $script:dummyCert.Subject | Should -MatchExactly 'CN=SelfSigned, O=TestOrg, L=TestCity, S=TestState, C=US' + + # Check certificate validity period. + $expectedNotBefore = $notBefore.Date + $expectedNotAfter = $notBefore.AddDays($validityDays).Date + + $script:dummyCert.NotBefore.ToUniversalTime().Date | Should -Be $expectedNotBefore + $script:dummyCert.NotAfter.ToUniversalTime().Date | Should -Be $expectedNotAfter + + # On Windows, verify the FriendlyName is set. + if ($IsWindows) { + $script:dummyCert.FriendlyName | Should -Be $script:friendlyName + } + } + + It 'Generates an ephemeral certificate when -Ephemeral is specified' { + # Define minimal parameters. + $commonName = 'ephemeral.example.com' + + # Call the function with the Ephemeral switch. + $cert = New-PodeSelfSignedCertificate -CommonName $commonName -Ephemeral + + # Validate that a certificate object is returned. + $cert | Should -BeOfType 'System.Security.Cryptography.X509Certificates.X509Certificate2' + + # Check that the certificate has a private key. + $cert.HasPrivateKey | Should -BeTrue + + # Note: Ephemeral certificates are created with non-persistent private keys. + # This test ensures the private key exists, though verifying non-persistence across sessions is out of scope. + } +} + +Describe 'Export-PodeCertificate Function' { + BeforeAll { + # Create a temporary directory for exported files. + $script:tempDir = Join-Path ([System.IO.Path]::GetTempPath()) ([guid]::NewGuid().ToString()) + New-Item -Path $script:tempDir -ItemType Directory -Force | Out-Null + + } + + Context 'File Export - PFX format' { + It 'Exports certificate to a PFX file' { + $filePathBase = Join-Path $script:tempDir 'dummycertPFX' + $script:pfxCertPath = Export-PodeCertificate -Certificate $script:dummyCert -Path $filePathBase -Format 'PFX' -CertificatePassword $script:dummyPassword + $script:pfxCertPath | Should -BeOfType pscustomobject + $script:pfxCertPath.CertificateFile | Should -Match '\.pfx' + (Test-Path $script:pfxCertPath.CertificateFile) | Should -BeTrue + } + } + + Context 'File Export - CER format' { + It 'Exports certificate to a CER file' { + $filePathBase = Join-Path $script:tempDir 'dummycertCER' + $script:cerCertPath = Export-PodeCertificate -Certificate $script:dummyCert -Path $filePathBase -Format 'CER' -CertificatePassword $script:dummyPassword + $script:cerCertPath | Should -BeOfType pscustomobject + $script:cerCertPath.CertificateFile | Should -Match '\.cer' + (Test-Path $script:cerCertPath.CertificateFile) | Should -BeTrue + } + } + + Context 'File Export - PEM format without private key' -Tag 'Exclude_DesktopEdition' { + It 'Exports certificate to a PEM file without private key' { + $filePathBase = Join-Path $script:tempDir 'dummycertPEM_NoKey' + if ($PSVersionTable.PSEdition -eq 'Desktop') { + { Export-PodeCertificate -Certificate $script:dummyCert -Path $filePathBase -Format 'PEM' -CertificatePassword $script:dummyPassword } | Should -Throw ($PodeLocale.pemCertificateNotSupportedByPwshVersionExceptionMessage -f $PSVersionTable.PSVersion) + } + else { + $output = Export-PodeCertificate -Certificate $script:dummyCert -Path $filePathBase -Format 'PEM' -CertificatePassword $script:dummyPassword + # The output for PEM (without key) is a string containing the file path. + $output | Should -BeOfType pscustomobject + $output.CertificateFile | Should -Match '\.pem' + (Test-Path -Path $output.CertificateFile) | Should -BeTrue + (Get-Content -Path $output.CertificateFile -Raw) | Should -Match '-----BEGIN CERTIFICATE-----' + } + } + } + + Context 'File Export - PEM format with private key' { + + It 'Exports certificate to a PEM file and exports the private key separately' { + $filePathBase = Join-Path $script:tempDir 'dummycertPEM_WithKey' + if ($PSVersionTable.PSEdition -eq 'Desktop') { + { Export-PodeCertificate -Certificate $script:dummyCert -Path $filePathBase -Format 'PEM' -IncludePrivateKey -CertificatePassword $script:dummyPassword } | Should -Throw ($PodeLocale.pemCertificateNotSupportedByPwshVersionExceptionMessage -f $PSVersionTable.PSVersion) + } + else { + $script:pemCertPath = Export-PodeCertificate -Certificate $script:dummyCert -Path $filePathBase -Format 'PEM' -IncludePrivateKey -CertificatePassword $script:dummyPassword + # When IncludePrivateKey is used, output is a hashtable. + $script:pemCertPath | Should -BeOfType 'pscustomobject' + $script:pemCertPath.CertificateFile | Should -Match '\.pem$' + $script:pemCertPath.PrivateKeyFile | Should -Match '\.key$' + (Test-Path $script:pemCertPath.CertificateFile) | Should -BeTrue + (Test-Path $script:pemCertPath.PrivateKeyFile) | Should -BeTrue + + (Get-Content -Path $script:pemCertPath.CertificateFile -Raw) | Should -Match '-----BEGIN CERTIFICATE-----' + (Get-Content -Path $script:pemCertPath.PrivateKeyFile -Raw) | Should -Match '-----BEGIN ENCRYPTED PRIVATE KEY-----' + } + } + } + + Context 'Windows Store Export' { + It 'Stores certificate in the Windows certificate store' -Tag 'Exclude_MacOs', 'Exclude_Linux' { + $script:thumbprint = $script:dummyCert.Thumbprint + + $result = Export-PodeCertificate -Certificate $script:dummyCert -CertificateStoreName 'My' -CertificateStoreLocation 'CurrentUser' + $result | Should -BeTrue + } + } +} + + + +Describe 'Import-PodeCertificate Function' { + Describe 'Sanity Check' { + BeforeAll { + # Create a dummy certificate using New-PodeSelfSignedCertificate. + # This call should work on PS 5.1 as well as Core. + $script:dummyCert = New-PodeSelfSignedCertificate -CommonName 'dummy.test' -ValidityDays 365 -Exportable + + + # Simulate Test-Path so that paths containing "exists" return true, others false. + Mock -CommandName Test-Path -MockWith { + param($Path, $PathType) + if ($Path[0].Contains('notexists')) { return $false } else { return $true } + } + + # Mock certificate import helper functions to return our dummy certificate. + Mock -CommandName Get-PodeCertificateByFile -MockWith { + param($Certificate, $SecurePassword, $PrivateKeyPath, $Persistent) + return $script:dummyCert + } + Mock -CommandName Get-PodeCertificateByThumbprint -MockWith { + param($Thumbprint, $StoreName, $StoreLocation) + return $script:dummyCert + } + Mock -CommandName Get-PodeCertificateByName -MockWith { + param($Name, $StoreName, $StoreLocation) + return $script:dummyCert + } + } + + Context 'When importing from a certificate file' { + It 'Throws an error if the certificate file does not exist' { + { + Import-PodeCertificate -Path 'C:\Certs\notexists.pfx' ` + -CertificatePassword (ConvertTo-SecureString 'pass' -AsPlainText -Force) + } | Should -Throw + } + + It 'Throws an error if a PrivateKeyPath is provided but does not exist' { + { + Import-PodeCertificate -Path 'C:\Certs\exists.pfx' ` + -PrivateKeyPath 'C:\Certs\notexists.key' ` + -CertificatePassword (ConvertTo-SecureString 'pass' -AsPlainText -Force) + } | Should -Throw + } + + It 'Imports a certificate from file when the certificate file exists' { + $cert = Import-PodeCertificate -Path 'C:\Certs\exists.pfx' ` + -CertificatePassword (ConvertTo-SecureString 'pass' -AsPlainText -Force) + $cert | Should -Be $script:dummyCert + } + + It 'Imports a certificate from file with the persistent flag when both files exist' { + $cert = Import-PodeCertificate -Path 'C:\Certs\exists.pfx' ` + -PrivateKeyPath 'C:\Certs\exists.key' ` + -CertificatePassword (ConvertTo-SecureString 'pass' -AsPlainText -Force) ` + -Exportable + $cert | Should -Be $script:dummyCert + } + } + + Context 'When importing from the certificate store by thumbprint' -Tag 'Exclude_MacOs', 'Exclude_Linux' { + It 'Retrieves a certificate using its thumbprint' { + $thumbprint = 'DUMMYTHUMBPRINT' + $cert = Import-PodeCertificate -CertificateThumbprint $thumbprint ` + -CertificateStoreName 'My' -CertificateStoreLocation 'CurrentUser' + $cert | Should -Be $script:dummyCert + } + } + + Context 'When importing from the certificate store by name' -Tag 'Exclude_MacOs', 'Exclude_Linux' { + It 'Retrieves a certificate using its subject name' { + $name = 'DummyCert' + $cert = Import-PodeCertificate -CertificateName $name ` + -CertificateStoreName 'My' -CertificateStoreLocation 'CurrentUser' + $cert | Should -Be $script:dummyCert + } + } + } + + Describe 'Import Functionality' { + AfterAll { + # Cleanup the temporary directory. + Remove-Item -Path $script:tempDir -Recurse -Force + } + + Context 'File Import - PFX format' { + It 'Imports certificate to a PFX file' { + + $cert = Import-PodeCertificate -Path $script:pfxCertPath.CertificateFile -CertificatePassword $script:dummyPassword + + $cert | Should -BeOfType System.Security.Cryptography.X509Certificates.X509Certificate2 + + # Validate the certificate's subject contains the common name. + $cert.Subject | Should -MatchExactly 'CN=SelfSigned, O=TestOrg, L=TestCity, S=TestState, C=US' + # On Windows, verify the FriendlyName is set. + if ($IsWindows) { + $cert.FriendlyName | Should -Be $script:friendlyName + } + } + } + + Context 'File Import - CER format' { + It 'Imports certificate to a CER file' { + $cert = Import-PodeCertificate -Path $script:cerCertPath.CertificateFile -CertificatePassword $script:dummyPassword + + $cert | Should -BeOfType System.Security.Cryptography.X509Certificates.X509Certificate2 + + # Validate the certificate's subject contains the common name. + $cert.Subject | Should -MatchExactly 'CN=SelfSigned, O=TestOrg, L=TestCity, S=TestState, C=US' + + } + } + + Context 'File Import - PEM format with private key' -Tag 'Exclude_DesktopEdition' { + It 'Imports certificate to a PEM file with private key' { + if ($PSVersionTable.PSEdition -eq 'Desktop') { + Mock Test-Path { $true } + { $cert = Import-PodeCertificate -Path ( Join-Path $script:tempDir 'dummycertPEM.pem') -CertificatePassword $script:dummyPassword -PrivateKeyPath ( Join-Path $script:tempDir 'dummycertPEM.key') } | + Should -Throw ($PodeLocale.pemCertificateNotSupportedByPwshVersionExceptionMessage -f $PSVersionTable.PSVersion) + } + else { + $cert = Import-PodeCertificate -Path $script:pemCertPath.CertificateFile -CertificatePassword $script:dummyPassword -PrivateKeyPath $script:pemCertPath.PrivateKeyFile + # The output for PEM (without key) is a string containing the file path. + $cert | Should -BeOfType System.Security.Cryptography.X509Certificates.X509Certificate2 + + # Validate the certificate's subject contains the common name. + $cert.Subject | Should -MatchExactly 'CN=SelfSigned, O=TestOrg, L=TestCity, S=TestState, C=US' + } + } + } + + Context 'Windows Store Import' { + It 'Stores certificate in the Windows certificate store' -Tag 'Exclude_MacOs', 'Exclude_Linux' { + $cert = Import-PodeCertificate -CertificateStoreName 'My' -CertificateStoreLocation 'CurrentUser' -CertificateThumbprint $script:thumbprint + $cert | Should -BeOfType System.Security.Cryptography.X509Certificates.X509Certificate2 + + # Validate the certificate's subject contains the common name. + $cert.Subject | Should -MatchExactly 'CN=SelfSigned, O=TestOrg, L=TestCity, S=TestState, C=US' + $cert.FriendlyName | Should -Be $script:friendlyName + } + } + } +} + diff --git a/tests/unit/Cryptography.Tests.ps1 b/tests/unit/Cryptography.Tests.ps1 index 423446f27..94566ce25 100644 --- a/tests/unit/Cryptography.Tests.ps1 +++ b/tests/unit/Cryptography.Tests.ps1 @@ -1,3 +1,7 @@ +using namespace System.Security.Cryptography + +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '')] +param() BeforeAll { $path = $PSCommandPath $src = (Split-Path -Parent -Path $path) -ireplace '[\\/]tests[\\/]unit', '/src/' @@ -55,4 +59,5 @@ Describe 'New-PodeSalt' { Mock Get-PodeRandomByte { return @(10, 10, 10) } New-PodeSalt -Length 3 | Should -Be 'CgoK' } -} \ No newline at end of file +} + \ No newline at end of file diff --git a/tests/unit/Jwt.Tests.ps1 b/tests/unit/Jwt.Tests.ps1 new file mode 100644 index 000000000..821e1e9d6 --- /dev/null +++ b/tests/unit/Jwt.Tests.ps1 @@ -0,0 +1,132 @@ +using namespace System.Security.Cryptography + +[Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSUseDeclaredVarsMoreThanAssignments', '')] +param() +BeforeAll { + $path = $PSCommandPath + $src = (Split-Path -Parent -Path $path) -ireplace '[\\/]tests[\\/]unit', '/src/' + Get-ChildItem "$($src)/*.ps1" -Recurse | Resolve-Path | ForEach-Object { . $_ } + Import-LocalizedData -BindingVariable PodeLocale -BaseDirectory (Join-Path -Path $src -ChildPath 'Locales') -FileName 'Pode' +} + +Describe 'New-PodeJwtSignature Function Tests' -Tags 'JWT' { + BeforeAll { + # Sample data + $testValue = 'TestData' + + $testSecret = [System.Text.Encoding]::UTF8.GetBytes('SuperSecretKey') + + $testPath = $(Split-Path -Parent -Path $(Split-Path -Parent -Path $path)) + $certificateTypes = @{ + 'RS256' = @{ + KeyType = 'RSA' + KeyLength = 2048 + RsaPaddingScheme = 'Pkcs1V15' + } + 'RS384' = @{ + KeyType = 'RSA' + KeyLength = 3072 + RsaPaddingScheme = 'Pkcs1V15' + } + 'RS512' = @{ + KeyType = 'RSA' + KeyLength = 4096 + RsaPaddingScheme = 'Pkcs1V15' + } + 'PS256' = @{ + KeyType = 'RSA' + KeyLength = 2048 + RsaPaddingScheme = 'Pss' + } + 'PS384' = @{ + KeyType = 'RSA' + KeyLength = 3072 + RsaPaddingScheme = 'Pss' + } + 'PS512' = @{ + KeyType = 'RSA' + KeyLength = 4096 + RsaPaddingScheme = 'Pss' + } + 'ES256' = @{ + KeyType = 'ECDSA' + KeyLength = 256 + } + 'ES384' = @{ + KeyType = 'ECDSA' + KeyLength = 384 + } + 'ES512' = @{ + KeyType = 'ECDSA' + KeyLength = 521 + } + } + + $PrivateKey = @{} + + foreach ($alg in $certificateTypes.keys) { + $PrivateKey[$alg] = New-PodeSelfSignedCertificate -Loopback -KeyType $certificateTypes[$alg].KeyType -KeyLength $certificateTypes[$alg].KeyLength -CertificatePurpose CodeSigning -Ephemeral + } + + } + + Context 'HMAC Signing Tests' { + It 'Should generate a valid HMAC-SHA256 signature' { + $result = New-PodeJwtSignature -Token $testValue -Algorithm HS256 -SecretBytes $testSecret + $result | Should -Match '^[A-Za-z0-9_-]+$' + } + + It 'Should generate a valid HMAC-SHA384 signature' { + $result = New-PodeJwtSignature -Token $testValue -Algorithm HS384 -SecretBytes $testSecret + $result | Should -Match '^[A-Za-z0-9_-]+$' + } + + It 'Should generate a valid HMAC-SHA512 signature' { + $result = New-PodeJwtSignature -Token $testValue -Algorithm HS512 -SecretBytes $testSecret + $result | Should -Match '^[A-Za-z0-9_-]+$' + } + } + + Context 'RSA Signing Tests' -Tag 'No_DesktopEdition' { + It 'Should generate a valid RSA-SHA256 signature' { + $alg = 'RS256' + $result = New-PodeJwtSignature -Token $testValue -X509Certificate $PrivateKey[$alg] + $result | Should -Match '^[A-Za-z0-9_-]+$' + } + + It 'Should generate a valid RSA-SHA384 signature' { + $result = New-PodeJwtSignature -Token $testValue -X509Certificate $PrivateKey['RS384'] + $result | Should -Match '^[A-Za-z0-9_-]+$' + } + + It 'Should generate a valid RSA-SHA512 signature' { + $result = New-PodeJwtSignature -Token $testValue -X509Certificate $PrivateKey['RS512'] + $result | Should -Match '^[A-Za-z0-9_-]+$' + } + } + + Context 'ECDSA Signing Tests' -Tag 'No_DesktopEdition' { + It 'Should generate a valid ECDSA-SHA256 signature' { + $result = New-PodeJwtSignature -Token $testValue -X509Certificate $PrivateKey['ES256'] + $result | Should -Match '^[A-Za-z0-9_-]+$' + } + + It 'Should generate a valid ECDSA-SHA384 signature' { + $result = New-PodeJwtSignature -Token $testValue -X509Certificate $PrivateKey['ES384'] + $result | Should -Match '^[A-Za-z0-9_-]+$' + } + + It 'Should generate a valid ECDSA-SHA512 signature' { + $result = New-PodeJwtSignature -Token $testValue -X509Certificate $PrivateKey['ES512'] + $result | Should -Match '^[A-Za-z0-9_-]+$' + } + } + + Context 'Algorithm NONE Tests' { + It 'Should throw an error if a secret is provided with NONE' { + { New-PodeJwtSignature -Token $testValue -Algorithm NONE -SecretBytes $testSecret } | Should -Throw + } + + } + +} \ No newline at end of file