├── .gitattributes ├── .github ├── FUNDING.yml └── workflows │ ├── build.yml │ └── validate.yml ├── .gitignore ├── EntraAuth ├── EntraAuth.Formats.ps1xml ├── EntraAuth.psd1 ├── EntraAuth.psm1 ├── LICENSE ├── changelog.md ├── functions │ ├── Authentication │ │ ├── Assert-EntraConnection.ps1 │ │ ├── Connect-EntraService.ps1 │ │ ├── Get-EntraService.ps1 │ │ ├── Get-EntraToken.ps1 │ │ ├── Import-EntraToken.ps1 │ │ ├── New-EntraCustomToken.ps1 │ │ ├── Register-EntraService.ps1 │ │ └── Set-EntraService.ps1 │ ├── Core │ │ └── Invoke-EntraRequest.ps1 │ ├── Other │ │ ├── New-EntraFilterBuilder.ps1 │ │ └── New-EntraServiceSelector.ps1 │ └── readme.md └── internal │ ├── classes │ ├── EntraToken.ps1 │ ├── Environment.ps1 │ ├── FilterBuilder.ps1 │ └── ServiceSelector.ps1 │ ├── functions │ ├── authentication │ │ ├── Connect-ServiceAzure.ps1 │ │ ├── Connect-ServiceBrowser.ps1 │ │ ├── Connect-ServiceCertificate.ps1 │ │ ├── Connect-ServiceClientSecret.ps1 │ │ ├── Connect-ServiceDeviceCode.ps1 │ │ ├── Connect-ServiceIdentity.ps1 │ │ ├── Connect-ServicePassword.ps1 │ │ ├── Connect-ServiceRefreshToken.ps1 │ │ ├── Get-VaultSecret.ps1 │ │ └── Read-AuthResponse.ps1 │ ├── other │ │ ├── ConvertTo-Base64.ps1 │ │ ├── ConvertTo-Hashtable.ps1 │ │ ├── ConvertTo-QueryString.ps1 │ │ ├── ConvertTo-SignedString.ps1 │ │ ├── Invoke-TerminatingException.ps1 │ │ ├── Read-TokenData.ps1 │ │ ├── Resolve-Certificate.ps1 │ │ ├── Resolve-RequestUri.ps1 │ │ └── Resolve-ScopeName.ps1 │ ├── readme.md │ └── ux │ │ ├── Assert-ServiceName.ps1 │ │ └── Get-ServiceCompletion.ps1 │ └── scripts │ ├── 01-variables.ps1 │ ├── 02-Services.ps1 │ └── readme.md ├── LICENSE ├── build ├── vsts-build.ps1 ├── vsts-prerequisites.ps1 └── vsts-validate.ps1 ├── docs ├── api-permissions.md ├── application-vs-delegate.md ├── authenticate-browser.md ├── authenticate-certificate.md ├── authenticate-clientsecret.md ├── authenticate-devicecode.md ├── building-on-entraauth.md ├── creating-applications.md ├── managing-applications.md ├── overview.md └── pictures │ ├── 01-01-Authentication.png │ ├── 01-02-Platform.png │ ├── 01-03-RedirectUri.png │ ├── 01-04-localhost.png │ ├── 01-05-Done.png │ ├── 02-01-Authentication.png │ ├── 02-02-Platform.png │ ├── 02-03-Localhost.png │ ├── 02-04-AdvancedSettings.png │ ├── 03-01-Certificates.png │ ├── 03-02-Selection.png │ ├── 03-03-Completed.png │ ├── 03-04-Finished.png │ ├── 04-01-DawnOfASecret.png │ ├── 04-02-Configuration.png │ ├── 04-03-Secret.png │ ├── A-01-AppRegistrations.png │ ├── A-02-NewRegistration.png │ ├── A-03-Setup.png │ ├── A-04-Portal.png │ ├── C-01-ApiPermission-Portal.png │ ├── C-02-RequestPermissions.png │ ├── C-03-ApplicationDelegate.png │ ├── C-04-ScopesFilter.png │ ├── C-05-ScopesAssign.png │ ├── C-06-ConsentPending.png │ ├── C-07-ConsentGranting.png │ ├── C-08-ConsentGranted.png │ ├── C-09-UnknownService.png │ ├── C-10-SearchingService.png │ ├── D-01-Overview.png │ ├── D-02-Properties.png │ ├── D-03-RememberToSave.png │ ├── D-04-AssignUsersGroups.png │ ├── D-05-Selection.png │ ├── D-06-Selection2.png │ ├── D-07-Assign.png │ ├── D-08-Assigned.png │ └── D-09-SigninLogs.png ├── readme.md └── tests ├── functions ├── New-EntraFilterBuilder.tests.ps1 └── readme.md ├── general ├── FileIntegrity.Exceptions.ps1 ├── FileIntegrity.Tests.ps1 ├── Help.Exceptions.ps1 ├── Help.Tests.ps1 ├── Manifest.Tests.ps1 └── PSScriptAnalyzer.Tests.ps1 ├── pester.ps1 └── readme.md /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | # These are supported funding model platforms 2 | 3 | github: 4 | FriedrichWeinmann 5 | patreon: # Replace with a single Patreon username 6 | open_collective: # Replace with a single Open Collective username 7 | ko_fi: # Replace with a single Ko-fi username 8 | tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel 9 | community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry 10 | liberapay: # Replace with a single Liberapay username 11 | issuehunt: # Replace with a single IssueHunt username 12 | otechie: # Replace with a single Otechie username 13 | custom: # Replace with up to 4 custom sponsorship URLs e.g., ['link1', 'link2'] 14 | -------------------------------------------------------------------------------- /.github/workflows/build.yml: -------------------------------------------------------------------------------- 1 | on: 2 | push: 3 | branches: 4 | - master 5 | - main 6 | 7 | jobs: 8 | build: 9 | 10 | runs-on: windows-latest 11 | 12 | steps: 13 | - uses: actions/checkout@v1 14 | - name: Install Prerequisites 15 | run: .\build\vsts-prerequisites.ps1 16 | shell: powershell 17 | - name: Validate 18 | run: .\build\vsts-validate.ps1 19 | shell: powershell 20 | - name: Build 21 | run: .\build\vsts-build.ps1 -ApiKey $env:APIKEY 22 | shell: powershell 23 | env: 24 | APIKEY: ${{ secrets.ApiKey }} -------------------------------------------------------------------------------- /.github/workflows/validate.yml: -------------------------------------------------------------------------------- 1 | on: [pull_request] 2 | 3 | jobs: 4 | validate: 5 | 6 | runs-on: windows-latest 7 | 8 | steps: 9 | - uses: actions/checkout@v1 10 | - name: Install Prerequisites 11 | run: .\build\vsts-prerequisites.ps1 12 | shell: powershell 13 | - name: Validate 14 | run: .\build\vsts-validate.ps1 15 | shell: powershell -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | publish 2 | TestResults 3 | experiments -------------------------------------------------------------------------------- /EntraAuth/EntraAuth.Formats.ps1xml: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | 5 | 6 | EntraToken 7 | 8 | EntraToken 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | Service 25 | 26 | 27 | Type 28 | 29 | 30 | ValidUntil 31 | 32 | 33 | Scopes 34 | 35 | 36 | ClientID 37 | 38 | 39 | TenantID 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | -------------------------------------------------------------------------------- /EntraAuth/EntraAuth.psd1: -------------------------------------------------------------------------------- 1 | @{ 2 | 3 | # Script module or binary module file associated with this manifest. 4 | RootModule = 'EntraAuth.psm1' 5 | 6 | # Version number of this module. 7 | ModuleVersion = '1.7.39' 8 | 9 | # Supported PSEditions 10 | # CompatiblePSEditions = @() 11 | 12 | # ID used to uniquely identify this module 13 | GUID = '3d8957b7-643a-48ad-97af-6a342483d578' 14 | 15 | # Author of this module 16 | Author = 'Friedrich Weinmann' 17 | 18 | # Company or vendor of this module 19 | CompanyName = ' ' 20 | 21 | # Copyright statement for this module 22 | Copyright = '(c) Friedrich Weinmann. All rights reserved.' 23 | 24 | # Description of the functionality provided by this module 25 | Description = 'Get Tokens from Entra ID' 26 | 27 | # Minimum version of the PowerShell engine required by this module 28 | # PowerShellVersion = '' 29 | 30 | # Name of the PowerShell host required by this module 31 | # PowerShellHostName = '' 32 | 33 | # Minimum version of the PowerShell host required by this module 34 | # PowerShellHostVersion = '' 35 | 36 | # Minimum version of Microsoft .NET Framework required by this module. This prerequisite is valid for the PowerShell Desktop edition only. 37 | # DotNetFrameworkVersion = '' 38 | 39 | # Minimum version of the common language runtime (CLR) required by this module. This prerequisite is valid for the PowerShell Desktop edition only. 40 | # ClrVersion = '' 41 | 42 | # Processor architecture (None, X86, Amd64) required by this module 43 | # ProcessorArchitecture = '' 44 | 45 | # Modules that must be imported into the global environment prior to importing this module 46 | # RequiredModules = @() 47 | 48 | # Assemblies that must be loaded prior to importing this module 49 | # RequiredAssemblies = @() 50 | 51 | # Script files (.ps1) that are run in the caller's environment prior to importing this module. 52 | # ScriptsToProcess = @() 53 | 54 | # Type files (.ps1xml) to be loaded when importing this module 55 | # TypesToProcess = @() 56 | 57 | # Format files (.ps1xml) to be loaded when importing this module 58 | FormatsToProcess = @('EntraAuth.Formats.ps1xml') 59 | 60 | # Modules to import as nested modules of the module specified in RootModule/ModuleToProcess 61 | # NestedModules = @() 62 | 63 | # Functions to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no functions to export. 64 | FunctionsToExport = @( 65 | 'Assert-EntraConnection' 66 | 'Connect-EntraService' 67 | 'Get-EntraService' 68 | 'Get-EntraToken' 69 | 'Import-EntraToken' 70 | 'Invoke-EntraRequest' 71 | 'New-EntraCustomToken' 72 | 'New-EntraFilterBuilder' 73 | 'New-EntraServiceSelector' 74 | 'Register-EntraService' 75 | 'Set-EntraService' 76 | ) 77 | 78 | # Cmdlets to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no cmdlets to export. 79 | # CmdletsToExport = '*' 80 | 81 | # Variables to export from this module 82 | # VariablesToExport = '*' 83 | 84 | # Aliases to export from this module, for best performance, do not use wildcards and do not delete the entry, use an empty array if there are no aliases to export. 85 | # AliasesToExport = '*' 86 | 87 | # DSC resources to export from this module 88 | # DscResourcesToExport = @() 89 | 90 | # List of all modules packaged with this module 91 | # ModuleList = @() 92 | 93 | # List of all files packaged with this module 94 | # FileList = @() 95 | 96 | # Private data to pass to the module specified in RootModule/ModuleToProcess. This may also contain a PSData hashtable with additional module metadata used by PowerShell. 97 | PrivateData = @{ 98 | 99 | PSData = @{ 100 | 101 | # Tags applied to this module. These help with module discovery in online galleries. 102 | Tags = @('EntraID', 'Token') 103 | 104 | # A URL to the license for this module. 105 | LicenseUri = 'https://github.com/FriedrichWeinmann/EntraAuth/blob/master/LICENSE' 106 | 107 | # A URL to the main website for this project. 108 | ProjectUri = 'https://github.com/FriedrichWeinmann/EntraAuth' 109 | 110 | # A URL to an icon representing this module. 111 | # IconUri = '' 112 | 113 | # ReleaseNotes of this module 114 | # ReleaseNotes = '' 115 | 116 | # Prerelease string of this module 117 | # Prerelease = '' 118 | 119 | # Flag to indicate whether the module requires explicit user acceptance for install/update/save 120 | # RequireLicenseAcceptance = $false 121 | 122 | # External dependent modules of this module 123 | # ExternalModuleDependencies = @() 124 | 125 | } # End of PSData hashtable 126 | 127 | } # End of PrivateData hashtable 128 | 129 | # HelpInfo URI of this module 130 | # HelpInfoURI = '' 131 | 132 | # Default prefix for commands exported from this module. Override the default prefix using Import-Module -Prefix. 133 | # DefaultCommandPrefix = '' 134 | 135 | } 136 | 137 | -------------------------------------------------------------------------------- /EntraAuth/EntraAuth.psm1: -------------------------------------------------------------------------------- 1 | $script:ModuleRoot = $PSScriptRoot 2 | 3 | foreach ($file in Get-ChildItem -Path "$PSScriptRoot/internal/classes" -Filter *.ps1 -Recurse) { 4 | . $file.FullName 5 | } 6 | 7 | foreach ($file in Get-ChildItem -Path "$PSScriptRoot/internal/functions" -Filter *.ps1 -Recurse) { 8 | . $file.FullName 9 | } 10 | 11 | foreach ($file in Get-ChildItem -Path "$PSScriptRoot/functions" -Filter *.ps1 -Recurse) { 12 | . $file.FullName 13 | } 14 | 15 | foreach ($file in Get-ChildItem -Path "$PSScriptRoot/internal/scripts" -Filter *.ps1 -Recurse) { 16 | . $file.FullName 17 | } -------------------------------------------------------------------------------- /EntraAuth/LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Friedrich Weinmann 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /EntraAuth/changelog.md: -------------------------------------------------------------------------------- 1 | # Changelog 2 | 3 | ## 1.7.39 (2025-04-15) 4 | 5 | + Upd: New-EntraFilterBuilder - Now supports both AND and OR logic. 6 | + Upd: New-EntraFilterBuilder - Now supports adding other filter builders as nested filter. 7 | + Upd: New-EntraFilterBuilder - Now allows specifying, whether values should be put in quotes. 8 | + Fix: New-EntraFilterBuilder - does not respect order of conditions for custom filters. 9 | 10 | ## 1.7.35 (2025-03-25) 11 | 12 | + New: New-EntraFilterBuilder - Creates a new OData-Filter construction helper. 13 | + New: New-EntraServiceSelector - Creates a helper type designed to help make a module implementing EntraAuth more flexible about what EntraAUth service to use. 14 | 15 | ## 1.6.33 (2025-03-10) 16 | 17 | + New: New-EntraCustomToken - Create a custom token compatible with EntraAuth. 18 | + Upd: Register-EntraService - add `RawOnly` parameter to have all requests to that service use raw processing by default. 19 | 20 | ## 1.5.31 (2025-03-05) 21 | 22 | + Upd: Invoke-EntraRequest - now allows overriding default header entries 23 | 24 | ## 1.5.30 (2025-03-05) 25 | 26 | + Upd: Connect-EntraService - now accepts "Graph" or "Azure" as ClientID, resolving the respective first party App IDs. 27 | + Fix: Connect-EntraService - fails to find a certificate by name, when the cert store contains only a single certificate 28 | 29 | ## 1.5.28 (2025-02-14) 30 | 31 | + New: Import-EntraToken - Imports a token into the local token store. 32 | + Upd: Connect-EntraService - added `-FallbackAzAccount` parameter to allow MSI authentication to fall back to the existing Az Session in case of trouble. 33 | + Upd: Connect-EntraService - enabled multiple secret names to be specified when logging in via Key Vault. 34 | + Upd: Token - default Query values are now copied onto the token from the service configuration. 35 | + Upd: Invoke-EntraRequest - uses default Query values from the token, rather than the service configuration 36 | 37 | ## 1.4.23 (2025-01-14) 38 | 39 | + Upd: Connect-EntraService - removed TenantID requirement for most delegate flows, defaulting the parameter to "organizations". TenantID on the managed token object is now read from the returned token. 40 | 41 | ## 1.4.22 (2024-12-04) 42 | 43 | + Upd: Connect-EntraService - added `-UseRefreshToken` parameter for delegate flows, showing the interactive prompts only if needed. 44 | 45 | ## 1.4.21 (2024-11-26) 46 | 47 | + Upd: Added support for authenticating using an existing refresh token 48 | + Fix: Invoke-EntraRequest - "body not supported with this method" error when using Get requests with a body (#23) 49 | 50 | ## 1.3.19 (2024-10-13) 51 | 52 | + Upd: Invoke-EntraRequest - `-Body` parameter now supports raw string or custom objects. 53 | 54 | ## 1.3.18 (2024-10-08) 55 | 56 | + Upd: Added support for authenticating using the current Az.Accounts session 57 | + Upd: Added support for Sovereign Clouds (USGov, USGovDOD, China) and custom authentication urls. 58 | + Fix: Certificate Logon fails on timezones after UTC 59 | 60 | ## 1.2.15 (2024-07-31) 61 | 62 | + Fix: Refresh token may fail to authenticate to correct application 63 | + Fix: Managed Identity authentication fails on Azure VMs 64 | 65 | ## 1.2.13 (2024-05-22) 66 | 67 | + New: Supporting Managed Identity (User-Assigned or System Managed) 68 | + Upd: Service Configuration - can specify default query parameters. 69 | 70 | ## 1.1.11 (2024-05-21) 71 | 72 | + New: Service configurations - Added configurations for Azure & AzureKeyVault. 73 | + Upd: Connect-EntraService - Added support for direct Key Vault integration. 74 | + Upd: Service configurations - Added capability to require additional parameters that modify the base Service Url. 75 | + Fix: Token Renewal - bad parameter ServiceUrl. 76 | + Fix: Asset-EntraConnection - bad error message when assertion fails. 77 | 78 | ## 1.0.6 (2024-05-15) 79 | 80 | + Upd: Invoke-EntraRequest - added -NoPaging parameter to support disabling paging. 81 | + Upd: Invoke-EntraRequest - added -Raw parameter to support returning unprocessed results. 82 | 83 | ## 1.0.4 (2024-04-19) 84 | 85 | + Fix: Connect-EntraService - fails to register new sessions (#10) 86 | 87 | ## 1.0.3 (2024-03-24) 88 | 89 | + Upd: Connect-EntraService - added -Resource parameter to allow creating tokens without requiring a service (#7) 90 | + Upd: Connect-EntraService - added -BrowserMode parameter to allow pasting the link to whatever browser you prefer (#3) 91 | + Fix: Connect Browser - unknown command when PSFramework is not installed 92 | 93 | ## 1.0.0 (2024-03-20) 94 | 95 | + Initial Release 96 | -------------------------------------------------------------------------------- /EntraAuth/functions/Authentication/Assert-EntraConnection.ps1: -------------------------------------------------------------------------------- 1 | function Assert-EntraConnection 2 | { 3 | <# 4 | .SYNOPSIS 5 | Asserts a connection has been established. 6 | 7 | .DESCRIPTION 8 | Asserts a connection has been established. 9 | Fails the calling command in a terminating exception if not connected yet. 10 | 11 | .PARAMETER Service 12 | The service to which a connection needs to be established. 13 | 14 | .PARAMETER Cmdlet 15 | The $PSCmdlet variable of the calling command. 16 | Used to execute the terminating exception in the caller scope if needed. 17 | 18 | .PARAMETER RequiredScopes 19 | Scopes needed, for better error messages. 20 | 21 | .EXAMPLE 22 | PS C:\> Assert-EntraConnection -Service 'Endpoint' -Cmdlet $PSCmdlet 23 | 24 | Silently does nothing if already connected to the specified defender for endpoint service. 25 | Kills the calling command if not yet connected. 26 | #> 27 | [CmdletBinding()] 28 | param ( 29 | [ArgumentCompleter({ Get-ServiceCompletion $args })] 30 | [Parameter(Mandatory = $true)] 31 | [string] 32 | $Service, 33 | 34 | [Parameter(Mandatory = $true)] 35 | $Cmdlet, 36 | 37 | [AllowEmptyCollection()] 38 | [string[]] 39 | $RequiredScopes 40 | ) 41 | 42 | process 43 | { 44 | if ($script:_EntraTokens["$Service"]) { return } 45 | 46 | $message = "Not connected yet! Use Connect-EntraService to establish a connection to '$Service' first." 47 | if ($RequiredScopes) { $message = $message + " Scopes required for this call: $($RequiredScopes -join ', ')"} 48 | Invoke-TerminatingException -Cmdlet $Cmdlet -Message $message -Category ConnectionError 49 | } 50 | } -------------------------------------------------------------------------------- /EntraAuth/functions/Authentication/Get-EntraService.ps1: -------------------------------------------------------------------------------- 1 | function Get-EntraService { 2 | <# 3 | .SYNOPSIS 4 | Returns the list of available Entra ID services that can be connected to. 5 | 6 | .DESCRIPTION 7 | Returns the list of available Entra ID services that can be connected to. 8 | Includes for each the endpoint/service url and the default requested scopes. 9 | 10 | .PARAMETER Name 11 | Name of the service to return. 12 | Defaults to: * 13 | 14 | .EXAMPLE 15 | PS C:\> Get-EntraService 16 | 17 | List all available services. 18 | #> 19 | [CmdletBinding()] 20 | param ( 21 | [ArgumentCompleter({ Get-ServiceCompletion $args })] 22 | [string] 23 | $Name = '*' 24 | ) 25 | process { 26 | $script:_EntraEndpoints.Values | Where-Object Name -like $Name 27 | } 28 | } -------------------------------------------------------------------------------- /EntraAuth/functions/Authentication/Get-EntraToken.ps1: -------------------------------------------------------------------------------- 1 | function Get-EntraToken { 2 | <# 3 | .SYNOPSIS 4 | Returns the session token of an Entra ID connection. 5 | 6 | .DESCRIPTION 7 | Returns the session token of an Entra ID connection. 8 | The main use for those token objects is calling their "GetHeader()" method to get an authentication header 9 | that automatically refreshes tokens as needed. 10 | 11 | .PARAMETER Service 12 | The service for which to retrieve the token. 13 | Defaults to: * 14 | 15 | .EXAMPLE 16 | PS C:\> Get-EntraToken 17 | 18 | Returns all current session tokens 19 | #> 20 | [CmdletBinding()] 21 | param ( 22 | [ArgumentCompleter({ Get-ServiceCompletion $args })] 23 | [string] 24 | $Service = '*' 25 | ) 26 | process { 27 | $script:_EntraTokens.Values | Where-Object Service -like $Service 28 | } 29 | } -------------------------------------------------------------------------------- /EntraAuth/functions/Authentication/Import-EntraToken.ps1: -------------------------------------------------------------------------------- 1 | function Import-EntraToken { 2 | <# 3 | .SYNOPSIS 4 | Imports a token into the local token store. 5 | 6 | .DESCRIPTION 7 | Imports a token into the local token store. 8 | This command is intended for use in a runspace scenario, for passing tokens from the main runspace into background environments. 9 | The input-token object is cloned into a runspace-local token object and registered to its service. 10 | 11 | After performing the conversion, it will try to renew the token object. 12 | 13 | .PARAMETER Token 14 | The token object to import. 15 | Should be a token object created by EntraAuth's Connect-EntraService command. 16 | Usually returned by Get-EntraToken, after finishing the connection. 17 | 18 | .PARAMETER PassThru 19 | Rather than registering the token into the Entra token store in memory, return it as an object. 20 | Useful for ronspace-localizing a token not associated with any given service. 21 | 22 | .PARAMETER NoRenew 23 | Do not renew the token after importing it. 24 | By default, newly localized tokens will try to renew themselves, to avoid parallel use of the same access token instance. 25 | 26 | .EXAMPLE 27 | PS C:\> Import-EntraToken -Token $using:tokens 28 | 29 | Imports all tokens stored in $tokens of the calling runspace. 30 | For use in scenarios such as background runspaces within "ForEach-Object -Parallel" 31 | #> 32 | [CmdletBinding()] 33 | param ( 34 | [Parameter(Mandatory = $true, ValueFromPipeline = $true)] 35 | [object[]] 36 | $Token, 37 | 38 | [switch] 39 | $PassThru, 40 | 41 | [switch] 42 | $NoRenew 43 | ) 44 | process { 45 | foreach ($tokenObject in $Token) { 46 | $newToken = [EntraToken]::new() 47 | foreach ($propertyName in $newToken.PSObject.Properties.Name) { 48 | if ($null -eq $tokenObject.$propertyName) { continue } 49 | if ($tokenObject.$propertyName -is [hashtable]) { 50 | $newToken.$propertyName = $tokenObject.$propertyName.Clone() 51 | } 52 | else { 53 | $newToken.$propertyName = $tokenObject.$propertyName 54 | } 55 | } 56 | 57 | if ( 58 | -not $newToken.Service -or 59 | -not $newToken.AccessToken -or 60 | -not $newToken.ClientID -or 61 | -not $newToken.TenantID 62 | ) { 63 | Invoke-TerminatingException -Cmdlet $PSCmdlet -Message "Invalid Input Object! An Entra token object must have a Service, AccessToken, ClientID and TenantID. Item received: $tokenObject" 64 | } 65 | 66 | #region Renew Token 67 | if ( 68 | -not $NoRenew -and 69 | ( 70 | $newToken.Type -notin 'DeviceCode', 'Browser' -or 71 | $newToken.RefreshToken 72 | ) 73 | ) { 74 | $newToken.RenewToken() 75 | } 76 | #endregion Renew Token 77 | 78 | if ($PassThru) { $newToken } 79 | else { $script:_EntraTokens[$newToken.Service] = $newToken } 80 | } 81 | } 82 | } -------------------------------------------------------------------------------- /EntraAuth/functions/Authentication/New-EntraCustomToken.ps1: -------------------------------------------------------------------------------- 1 | function New-EntraCustomToken { 2 | <# 3 | .SYNOPSIS 4 | Create a custom token compatible with EntraAuth. 5 | 6 | .DESCRIPTION 7 | Create a custom token compatible with EntraAuth. 8 | This allows directly integrating APIs that do not operate with default OAuth into the EntraAuth toolset. 9 | 10 | .PARAMETER ServiceUrl 11 | The URL requests are sent against. 12 | 13 | .PARAMETER HeaderCode 14 | The code that calculates the header (including authentication information) to include in the request. 15 | The scriptblock receives one argument: The token itself. 16 | Must return a single hashtable. 17 | 18 | .PARAMETER Service 19 | The name of the service to register the token under. 20 | Does not have to be a formally registered service, can be an arbitrary name. 21 | Specifying this parameter prevents the token from being returned as output, unless combined with -PassThru. 22 | 23 | .PARAMETER PassThru 24 | Return the token as output. 25 | By default, when specifying a service name, this command produces no output. 26 | 27 | .PARAMETER TenantID 28 | TenantID to connect to. 29 | Purely cosmetic, unless accessed from the header code. 30 | 31 | .PARAMETER ClientID 32 | ClientID to connect as. 33 | Purely cosmetic, unless accessed from the header code. 34 | 35 | .PARAMETER Header 36 | Header information to include in all requests against this API. 37 | Additional header entries to include in every request. 38 | Will be added to those returned from the HeaderCode and need not be considered within that code. 39 | 40 | .PARAMETER Query 41 | Additional query parameters to include in all requests against this API. 42 | 43 | .PARAMETER Data 44 | Additional information to store in the token object. 45 | Used by the HeaderCode scriptblock. 46 | Use this parameter to include information such as PATs, API keys or similar pieces of information. 47 | 48 | .PARAMETER RawOnly 49 | All requests throug this token should not use the default response processing. 50 | This will prevent Invoke-EntraRequest from providing most of its usual assistance. 51 | 52 | .EXAMPLE 53 | PS C:\> New-EntraCustomToken -Service AzDevMyProject -ServiceUrl 'https://dev.azure.com/contoso/myproject/_apis/wit' -Data @{ PAT = $pat } -HeaderCode { 54 | param ($Token) 55 | $base64AuthInfo = [Convert]::ToBase64String([System.Text.Encoding]::UTF8.GetBytes(':' + $($Token.Data.PAT | ConvertFrom-SecureString -AsPlainText))) 56 | return @{ 57 | Authorization = "Basic $base64AuthInfo" 58 | 'Content-Type' = 'application/json' 59 | } 60 | } 61 | 62 | Registers a new token for Azure DevOps, using PAT to authenticate. 63 | #> 64 | [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")] 65 | [CmdletBinding()] 66 | param ( 67 | [Parameter(Mandatory = $true)] 68 | [string] 69 | $ServiceUrl, 70 | 71 | [Parameter(Mandatory = $true)] 72 | [scriptblock] 73 | $HeaderCode, 74 | 75 | [string] 76 | $Service, 77 | 78 | [switch] 79 | $PassThru, 80 | 81 | [string] 82 | $TenantID = '', 83 | 84 | [string] 85 | $ClientID = '', 86 | 87 | [hashtable] 88 | $Header = @{}, 89 | 90 | [hashtable] 91 | $Query = @{}, 92 | 93 | [hashtable] 94 | $Data = @{}, 95 | 96 | [switch] 97 | $RawOnly 98 | ) 99 | process { 100 | $newToken = [EntraToken]::new() 101 | $newToken.Type = 'Custom' 102 | $newToken.ServiceUrl = $ServiceUrl 103 | $newToken.HeaderCode = $HeaderCode 104 | if ($Service) { $newToken.Service = $Service } 105 | else { $newToken.Service = '' } 106 | $newToken.TenantID = $TenantID 107 | $newToken.ClientID = $ClientID 108 | $newToken.Header = $Header.Clone() 109 | $newToken.Query = $Query.Clone() 110 | $newToken.Data = $Data.Clone() 111 | $newToken.RawOnly = $RawOnly.ToBool() 112 | 113 | if ($Service) { 114 | $script:_EntraTokens[$Service] = $newToken 115 | } 116 | if ($PassThru -or -not $Service) { 117 | $newToken 118 | } 119 | } 120 | } -------------------------------------------------------------------------------- /EntraAuth/functions/Authentication/Register-EntraService.ps1: -------------------------------------------------------------------------------- 1 | function Register-EntraService { 2 | <# 3 | .SYNOPSIS 4 | Define a new Entra ID Service to connect to. 5 | 6 | .DESCRIPTION 7 | Define a new Entra ID Service to connect to. 8 | This allows defining new endpoints to connect to ... or overriding existing endpoints to a different configuration. 9 | 10 | .PARAMETER Name 11 | Name of the Service. 12 | 13 | .PARAMETER ServiceUrl 14 | The base Url requests will use. 15 | 16 | .PARAMETER Resource 17 | The Resource ID. Used when connecting to identify which scopes of an App Registration to use. 18 | 19 | .PARAMETER DefaultScopes 20 | Default scopes to request. 21 | Used in interactive delegate flows to provide a good default user experience. 22 | Default scopes should usually include common read scenarios. 23 | 24 | .PARAMETER Header 25 | Header data to include in each request. 26 | 27 | .PARAMETER HelpUrl 28 | Link for more information about this service. 29 | Ideally to documentation that helps setting up the connection. 30 | 31 | .PARAMETER NoRefresh 32 | Delegate authentication flows should not request refresh tokens. 33 | By default, delegate authentication flows will automatically request offline_access to get a refresh token. 34 | This refresh token allows requesting new tokens when the current one is expiring without requiring additional 35 | interactive logon actions. 36 | However, not all services support this scope. 37 | 38 | .PARAMETER Parameters 39 | Extra parameters a request will require. 40 | It expects a hashtable with the key being the parameter name, and the value being a description of that parameter. 41 | The ServiceUrl must include a placeholder for each parameter to insert into it. 42 | 43 | Example: 44 | Parameter: @{ VaultName = 'Name of the Key Vault to execute against' } 45 | ServiceUrl: https://%VAULTNAME%.vault.azure.net 46 | 47 | .PARAMETER Query 48 | Extra Query Parameters to automatically include on all requests. 49 | 50 | .PARAMETER RawOnly 51 | Disable default API response handling. 52 | By default, when executing a request via Invoke-EntraRequest, the response is processed as if it were a default Graph API standard response. 53 | Many other MS APIs follow the same standard, but not all do so. 54 | When enabling this setting on a service, all requests against that service will NOT have that processing applied and instead return raw responses. 55 | 56 | .PARAMETER Environment 57 | What environment this service should connect to. 58 | Defaults to: 'Global' 59 | 60 | .PARAMETER AuthenticationUrl 61 | The url used for the authentication requests to retrieve tokens. 62 | Usually determined by the "Environment" parameter, but may be overridden in case of need. 63 | 64 | .EXAMPLE 65 | PS C:\> Register-EntraService -Name Endpoint -ServiceUrl 'https://api.securitycenter.microsoft.com/api' -Resource 'https://api.securitycenter.microsoft.com' 66 | 67 | Registers the defender for endpoint API as a service. 68 | #> 69 | [CmdletBinding()] 70 | param ( 71 | [Parameter(Mandatory = $true)] 72 | [string] 73 | $Name, 74 | 75 | [Parameter(Mandatory = $true)] 76 | [string] 77 | $ServiceUrl, 78 | 79 | [Parameter(Mandatory = $true)] 80 | [string] 81 | $Resource, 82 | 83 | [AllowEmptyCollection()] 84 | [string[]] 85 | $DefaultScopes = @(), 86 | 87 | [hashtable] 88 | $Header = @{}, 89 | 90 | [string] 91 | $HelpUrl, 92 | 93 | [switch] 94 | $NoRefresh, 95 | 96 | [hashtable] 97 | $Parameters = @{}, 98 | 99 | [Hashtable] 100 | $Query = @{}, 101 | 102 | [switch] 103 | $RawOnly, 104 | 105 | [Environment] 106 | $Environment = 'Global', 107 | 108 | [string] 109 | $AuthenticationUrl 110 | ) 111 | process { 112 | $command = Get-Command Invoke-EntraRequest 113 | $badParameters = $Parameters.Keys | Where-Object { $_ -in $command.Parameters.Keys } 114 | if ($badParameters) { 115 | Invoke-TerminatingException -Cmdlet $PSCmdlet -Message "Cannot define parameters that collide with Invoke-EntraRequest: $($badParameters -join ', ')" 116 | } 117 | $authUrl = switch ("$Environment") { 118 | 'China' { 'https://login.chinacloudapi.cn' } 119 | 'USGov' { 'https://login.microsoftonline.us' } 120 | 'USGovDOD' { 'https://login.microsoftonline.us' } 121 | default { 'https://login.microsoftonline.com' } 122 | } 123 | if ($AuthenticationUrl) { $authUrl = $AuthenticationUrl.TrimEnd('/') } 124 | 125 | $script:_EntraEndpoints[$Name] = [PSCustomObject]@{ 126 | PSTypeName = 'EntraAuth.Service' 127 | Name = $Name 128 | ServiceUrl = $ServiceUrl 129 | Resource = $Resource 130 | DefaultScopes = $DefaultScopes 131 | Header = $Header 132 | HelpUrl = $HelpUrl 133 | NoRefresh = $NoRefresh.ToBool() 134 | Parameters = $Parameters 135 | Query = $Query 136 | RawOnly = $RawOnly.ToBool() 137 | AuthenticationUrl = $authUrl 138 | } 139 | } 140 | } -------------------------------------------------------------------------------- /EntraAuth/functions/Authentication/Set-EntraService.ps1: -------------------------------------------------------------------------------- 1 | function Set-EntraService { 2 | <# 3 | .SYNOPSIS 4 | Modify the settings on an existing Service configuration. 5 | 6 | .DESCRIPTION 7 | Modify the settings on an existing Service configuration. 8 | Service configurations are defined using Register-EntraService and define how connections and requests to a specific API service / endpoint are performed. 9 | 10 | .PARAMETER Name 11 | The name of the already existing Service configuration. 12 | 13 | .PARAMETER ServiceUrl 14 | The base Url requests will use. 15 | 16 | .PARAMETER Resource 17 | The Resource ID. Used when connecting to identify which scopes of an App Registration to use. 18 | 19 | .PARAMETER DefaultScopes 20 | Default scopes to request. 21 | Used in interactive delegate flows to provide a good default user experience. 22 | Default scopes should usually include common read scenarios. 23 | 24 | .PARAMETER Header 25 | Header data to include in each request. 26 | 27 | .PARAMETER HelpUrl 28 | Link for more information about this service. 29 | Ideally to documentation that helps setting up the connection. 30 | 31 | .PARAMETER NoRefresh 32 | Delegate authentication flows should not request refresh tokens. 33 | By default, delegate authentication flows will automatically request offline_access to get a refresh token. 34 | This refresh token allows requesting new tokens when the current one is expiring without requiring additional 35 | interactive logon actions. 36 | However, not all services support this scope. 37 | 38 | .EXAMPLE 39 | PS C:\> Set-EntraService -Name Endpoint -ServiceUrl 'https://api-us.securitycenter.microsoft.com/api' 40 | 41 | Changes the service url for the "Endpoint" service to 'https://api-us.securitycenter.microsoft.com/api'. 42 | Note: It is generally recommened to select the service url most suitable for your tenant, geographically: 43 | https://learn.microsoft.com/en-us/microsoft-365/security/defender-endpoint/api/exposed-apis-list?view=o365-worldwide#versioning 44 | #> 45 | [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")] 46 | [CmdletBinding()] 47 | param ( 48 | [Parameter(Mandatory = $true, ValueFromPipeline = $true, ValueFromPipelineByPropertyName = $true)] 49 | [ArgumentCompleter({ Get-ServiceCompletion $args })] 50 | [ValidateScript({ Assert-ServiceName -Name $_ })] 51 | [string] 52 | $Name, 53 | 54 | [string] 55 | $ServiceUrl, 56 | 57 | [string] 58 | $Resource, 59 | 60 | [AllowEmptyCollection()] 61 | [string[]] 62 | $DefaultScopes, 63 | 64 | [Hashtable] 65 | $Header, 66 | 67 | [string] 68 | $HelpUrl, 69 | 70 | [switch] 71 | $NoRefresh 72 | ) 73 | process { 74 | $service = $script:_EntraEndpoints.$Name 75 | if ($PSBoundParameters.Keys -contains 'ServiceUrl') { $service.ServiceUrl = $ServiceUrl } 76 | if ($PSBoundParameters.Keys -contains 'Resource') { $service.Resource = $Resource } 77 | if ($PSBoundParameters.Keys -contains 'DefaultScopes') { $service.DefaultScopes = $DefaultScopes } 78 | if ($PSBoundParameters.Keys -contains 'Header') { $service.Header = $Header } 79 | if ($PSBoundParameters.Keys -contains 'HelpUrl') { $service.HelpUrl = $HelpUrl } 80 | if ($PSBoundParameters.Keys -contains 'NoRefresh') { $service.HelpUrl = $NoRefresh.ToBool() } 81 | } 82 | } -------------------------------------------------------------------------------- /EntraAuth/functions/Core/Invoke-EntraRequest.ps1: -------------------------------------------------------------------------------- 1 | function Invoke-EntraRequest { 2 | <# 3 | .SYNOPSIS 4 | Executes a web request against an entra-based service 5 | 6 | .DESCRIPTION 7 | Executes a web request against an entra-based service 8 | Handles all the authentication details once connected using Connect-EntraService. 9 | 10 | .PARAMETER Path 11 | The relative path of the endpoint to query. 12 | For example, to retrieve Microsoft Graph users, it would be a plain "users". 13 | To access details on a particular defender for endpoint machine instead it would look thus: "machines/1e5bc9d7e413ddd7902c2932e418702b84d0cc07" 14 | 15 | .PARAMETER Body 16 | Any body content needed for the request. 17 | 18 | .PARAMETER Query 19 | Any query content to include in the request. 20 | In opposite to -Body this is attached to the request Url and usually used for filtering. 21 | 22 | .PARAMETER Method 23 | The Rest Method to use. 24 | Defaults to GET 25 | 26 | .PARAMETER RequiredScopes 27 | Any authentication scopes needed. 28 | Used for documentary purposes only. 29 | 30 | .PARAMETER Header 31 | Any additional headers to include on top of authentication and content-type. 32 | 33 | .PARAMETER Service 34 | Which service to execute against. 35 | Determines the API endpoint called to. 36 | Defaults to "Graph" 37 | 38 | .PARAMETER SerializationDepth 39 | How deeply to serialize the request body when converting it to json. 40 | Defaults to: 99 41 | 42 | .PARAMETER Token 43 | A Token as created and maintained by this module. 44 | If specified, it will override the -Service parameter. 45 | 46 | .PARAMETER NoPaging 47 | Do not automatically page through responses sets. 48 | By default, Invoke-EntraRequest is going to keep retrieving result pages until all data has been retrieved. 49 | 50 | .PARAMETER Raw 51 | Do not process the response object and instead return the raw result returned by the API. 52 | 53 | .EXAMPLE 54 | PS C:\> Invoke-EntraRequest -Path 'alerts' -RequiredScopes 'Alert.Read' 55 | 56 | Return a list of defender alerts. 57 | #> 58 | [CmdletBinding(DefaultParameterSetName = 'default')] 59 | param ( 60 | [Parameter(Mandatory = $true)] 61 | [string] 62 | $Path, 63 | 64 | $Body, 65 | 66 | [Hashtable] 67 | $Query = @{ }, 68 | 69 | [string] 70 | $Method = 'GET', 71 | 72 | [string[]] 73 | $RequiredScopes, 74 | 75 | [hashtable] 76 | $Header = @{}, 77 | 78 | [ArgumentCompleter({ Get-ServiceCompletion $args })] 79 | [ValidateScript({ Assert-ServiceName -Name $_ -IncludeTokens })] 80 | [string] 81 | $Service = $script:_DefaultService, 82 | 83 | [ValidateRange(1, 666)] 84 | [int] 85 | $SerializationDepth = 99, 86 | 87 | [EntraToken] 88 | $Token, 89 | 90 | [switch] 91 | $NoPaging, 92 | 93 | [switch] 94 | $Raw 95 | ) 96 | 97 | DynamicParam { 98 | if ($Resource) { return } 99 | 100 | $actualService = $Service 101 | if (-not $actualService) { $actualService = $script:_DefaultService } 102 | $serviceObject = $script:_EntraEndpoints.$actualService 103 | if (-not $serviceObject) { return } 104 | if ($serviceObject.Parameters.Count -lt 1) { return } 105 | 106 | $results = [System.Management.Automation.RuntimeDefinedParameterDictionary]::new() 107 | foreach ($pair in $serviceObject.Parameters.GetEnumerator()) { 108 | $parameterAttribute = [System.Management.Automation.ParameterAttribute]::new() 109 | $parameterAttribute.ParameterSetName = '__AllParameterSets' 110 | $parameterAttribute.Mandatory = $true 111 | $parameterAttribute.HelpMessage = $pair.Value 112 | $attributesCollection = [System.Collections.ObjectModel.Collection[System.Attribute]]::new() 113 | $attributesCollection.Add($parameterAttribute) 114 | $RuntimeParam = [System.Management.Automation.RuntimeDefinedParameter]::new($pair.Key, [object], $attributesCollection) 115 | 116 | $results.Add($pair.Key, $RuntimeParam) 117 | } 118 | 119 | $results 120 | } 121 | 122 | begin { 123 | if ($Token) { 124 | $tokenObject = $Token 125 | } 126 | else { 127 | Assert-EntraConnection -Service $Service -Cmdlet $PSCmdlet -RequiredScopes $RequiredScopes 128 | $tokenObject = $script:_EntraTokens.$Service 129 | } 130 | 131 | $serviceObject = $script:_EntraEndpoints.$($tokenObject.Service) 132 | } 133 | process { 134 | $parameters = @{ 135 | Method = $Method 136 | Uri = Resolve-RequestUri -TokenObject $tokenObject -ServiceObject $serviceObject -BoundParameters $PSBoundParameters 137 | } 138 | 139 | if ($PSBoundParameters.Keys -contains 'Body') { 140 | if ($Body -is [string]) { 141 | $parameters.Body = $Body 142 | } 143 | else { 144 | $parameters.Body = $Body | ConvertTo-Json -Compress -Depth $SerializationDepth 145 | } 146 | } 147 | # In PS5.1, some methods cannot contain a body 148 | $noBodyMethods = 'Default', 'Get', 'Head' 149 | if ($PSVersionTable.PSVersion.Major -lt 6 -and $Method -in $noBodyMethods) { 150 | $parameters.Remove('Body') 151 | } 152 | 153 | $parameters.Uri += ConvertTo-QueryString -QueryHash $Query -DefaultQuery $tokenObject.Query 154 | 155 | do { 156 | $tempHeader = $tokenObject.GetHeader().Clone() # GetHeader() automatically refreshes expired tokens 157 | foreach ($pair in $Header.GetEnumerator()) { $tempHeader[$pair.Key] = $pair.Value } 158 | $parameters.Headers = $tempHeader 159 | Write-Verbose "Executing Request: $($Method) -> $($parameters.Uri)" 160 | try { $result = Invoke-RestMethod @parameters -ErrorAction Stop } 161 | catch { 162 | $letItBurn = $true 163 | $failure = $_ 164 | 165 | if ($_.ErrorDetails.Message) { 166 | $details = $_.ErrorDetails.Message | ConvertFrom-Json 167 | if ($details.Error.Code -eq 'TooManyRequests') { 168 | Write-Verbose "Throttling: $($details.error.message)" 169 | $delay = 1 + ($details.error.message -replace '^.+ (\d+) .+$', '$1' -as [int]) 170 | if ($delay -gt 5) { Write-Warning "Request is being throttled for $delay seconds" } 171 | Start-Sleep -Seconds $delay 172 | try { 173 | $result = Invoke-RestMethod @parameters -ErrorAction Stop 174 | $letItBurn = $false 175 | } 176 | catch { 177 | $failure = $_ 178 | } 179 | } 180 | } 181 | 182 | if ($letItBurn) { 183 | Write-Warning "Request failed: $($Method) -> $($parameters.Uri)" 184 | $PSCmdlet.ThrowTerminatingError($failure) 185 | } 186 | } 187 | if (-not $Raw -and -not $tokenObject.RawOnly -and $result.PSObject.Properties.Where{ $_.Name -eq 'value' }) { $result.Value } 188 | else { $result } 189 | $parameters.Uri = $result.'@odata.nextLink' 190 | } 191 | while ($parameters.Uri -and -not $NoPaging) 192 | } 193 | } -------------------------------------------------------------------------------- /EntraAuth/functions/Other/New-EntraFilterBuilder.ps1: -------------------------------------------------------------------------------- 1 | function New-EntraFilterBuilder { 2 | <# 3 | .SYNOPSIS 4 | Creates a new OData-Filter construction helper. 5 | 6 | .DESCRIPTION 7 | Creates a new OData-Filter construction helper. 8 | This helper class was designed to simplify the creation of OData filters for APIs such as the Microsoft Graph API. 9 | Call the ".Add(...)" method to specify filter conditions. 10 | 11 | There are three ways to do so: 12 | A) .Add(property, operator, value) 13 | A single property comparison, with a specific operator and value. 14 | 15 | B) .Add(customFilter) 16 | A custom piece of OData filter text. 17 | 18 | C) .Add(filterbuilder) 19 | The output of another filterbuilder becomes part of the conditions for this one. 20 | This allows building complex, nested filter statements. 21 | 22 | Finally, call ".Get()" to retrieve the full OData filter string or ".GetHeader()" to retrieve the filter header hashtable. 23 | 24 | For more in-depth help with the type, call the '.GetHelp()' method on the object. 25 | 26 | Useful Resources: 27 | List of valid filter conditions: 28 | https://learn.microsoft.com/en-us/graph/filter-query-parameter?tabs=http#operators-and-functions-supported-in-filter-expressions 29 | 30 | .PARAMETER Logic 31 | The logic by which individual filter conditions are merged. 32 | Options: 33 | - AND (default) 34 | - OR 35 | 36 | .EXAMPLE 37 | PS C:\> $filter = New-EntraFilterBuilder 38 | PS C:\> $filter.Add('displayName', 'eq', 'John Doe') 39 | PS C:\> $filter.Add('organization', 'in', @('Contoso', 'Fabrikam')) 40 | PS C:\> $filter.Get() 41 | 42 | Will return: "displayName eq 'John Doe' and organization in ('Contoso', 'Fabrikam')" 43 | #> 44 | [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")] 45 | #%UNCOMMENT%[OutputType([FilterBuilder])] 46 | [CmdletBinding()] 47 | param ( 48 | [ValidateSet('AND','OR')] 49 | [string] 50 | $Logic = 'AND' 51 | ) 52 | 53 | process { 54 | [FilterBuilder]::new($Logic) 55 | } 56 | } -------------------------------------------------------------------------------- /EntraAuth/functions/Other/New-EntraServiceSelector.ps1: -------------------------------------------------------------------------------- 1 | function New-EntraServiceSelector { 2 | <# 3 | .SYNOPSIS 4 | Creates a helper type designed to help make a module implementing EntraAuth more flexible about what EntraAUth service to use. 5 | 6 | .DESCRIPTION 7 | Creates a helper type designed to help make a module implementing EntraAuth more flexible about what EntraAUth service to use. 8 | 9 | While a module can easily define what service to use when calling Invoke-EntraRequest, this has some concerns: 10 | + If multiple modules require the same service, they may interfere with each other by trying to use separate applications or having different scope requirements. 11 | + If each module defines their own service instance (e.g. "Graph.MyModule"), then a script using multiple modules needs to authenticate multiple times, even if they all could use the same connection/token. 12 | 13 | The Service Selector aims to be a simple solution to this problem. 14 | It is intended for _Modules_ that implement EntraAuth, not individual scripts. 15 | 16 | To fully execute on this, you will need to implement this in three locations: 17 | - During Module Import: Declare defaults & Selector. 18 | - At the beginning of your functions: Select chosen services. 19 | - When executing requests: Use service as chosen. 20 | 21 | #======================================================================================================= 22 | # During Module Import 23 | $script:_services = @{ Graph = 'Graph'; MDE = 'Endpoint' } 24 | $script:_serviceSelector = New-EntraServiceSelector -DefaultServices $script:_services 25 | 26 | # During the Begin stage of each function using EntraAuth 27 | begin { 28 | $services = $script:_serviceSelector.GetServiceMap($ServiceMap) # $ServiceMap is a hashtable parameter offered by your function 29 | Assert-EntraConnection -Cmdlet $PSCmdlet -Service $services.Graph 30 | } 31 | 32 | # When executing the actual request, later in the function 33 | Invoke-EntraService -Service $services.Graph -Path users 34 | #======================================================================================================= 35 | 36 | With this, somebody could call your command - let's call it "Get-DepartmentUser" - like this: 37 | Get-DepartmentUser -ServiceMap @{ Graph = 'GraphBeta' } 38 | And your function would use the beta version of the Graph api, without affecting any other script or module calling your function. 39 | 40 | Example Implementations: 41 | - During Module Import: 42 | https://github.com/FriedrichWeinmann/EntraAuth.Graph.Application/blob/master/EntraAuth.Graph.Application/internal/scripts/variables.ps1 43 | - Used in Functions: 44 | https://github.com/FriedrichWeinmann/EntraAuth.Graph.Application/blob/3c5e9f3de31fd7946e6fe9ebdb938986165ff5ca/EntraAuth.Graph.Application/functions/Get-EAGAppRegistration.ps1#L78 45 | 46 | .PARAMETER DefaultServices 47 | The Default services to use. 48 | Provide a hashtable of Labels mapping to EntraAuth services. 49 | Example: 50 | @{ Graph = 'Graph'; MDE = 'Endpoint' } 51 | The key is what you use in your code as a label, the Value is the actual service in EntraAuth. 52 | 53 | .EXAMPLE 54 | PS C:\> $script:_serviceSelector = New-EntraServiceSelector -DefaultServices $script:_services 55 | 56 | Creates a new ServiceSelector object and stores it in $script:_serviceSelector 57 | #> 58 | [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSUseShouldProcessForStateChangingFunctions", "")] 59 | #%UNCOMMENT%[OutputType([ServiceSelector])] 60 | [CmdletBinding()] 61 | param ( 62 | [Parameter(Mandatory = $true)] 63 | [hashtable] 64 | $DefaultServices 65 | ) 66 | process { 67 | [ServiceSelector]::new($DefaultServices) 68 | } 69 | } -------------------------------------------------------------------------------- /EntraAuth/functions/readme.md: -------------------------------------------------------------------------------- 1 | # Functions 2 | 3 | Folder for all the functions the user is supposed to have access to. 4 | -------------------------------------------------------------------------------- /EntraAuth/internal/classes/EntraToken.ps1: -------------------------------------------------------------------------------- 1 | class EntraToken { 2 | #region Token Data 3 | [string]$AccessToken 4 | [System.DateTime]$ValidAfter 5 | [System.DateTime]$ValidUntil 6 | [string[]]$Scopes 7 | [string]$RefreshToken 8 | [string]$Audience 9 | [string]$Issuer 10 | [PSObject]$TokenData 11 | #endregion Token Data 12 | 13 | #region Connection Data 14 | [string]$Service 15 | [string]$Type 16 | [string]$ClientID 17 | [string]$TenantID 18 | [string]$ServiceUrl 19 | [string]$AuthenticationUrl 20 | [Hashtable]$Header = @{} 21 | [Hashtable]$Query = @{} 22 | [bool]$RawOnly 23 | 24 | [string]$IdentityID 25 | [string]$IdentityType 26 | 27 | # Workflow: Client Secret 28 | [System.Security.SecureString]$ClientSecret 29 | 30 | # Workflow: Certificate 31 | [System.Security.Cryptography.X509Certificates.X509Certificate2]$Certificate 32 | 33 | # Workflow: Username & Password 34 | [PSCredential]$Credential 35 | 36 | # Workflow: Key Vault 37 | [string]$VaultName 38 | [string]$SecretName 39 | 40 | # Workflow: Az.Accounts 41 | [string]$ShowDialog 42 | 43 | # Workflow: Custom Token 44 | [scriptblock]$HeaderCode 45 | [hashtable]$Data = @{} 46 | 47 | #endregion Connection Data 48 | 49 | #region Constructors 50 | EntraToken([string]$Service, [string]$ClientID, [string]$TenantID, [Securestring]$ClientSecret, [string]$ServiceUrl, [string]$AuthenticationUrl) { 51 | $this.Service = $Service 52 | $this.ClientID = $ClientID 53 | $this.TenantID = $TenantID 54 | $this.ClientSecret = $ClientSecret 55 | $this.ServiceUrl = $ServiceUrl 56 | $this.AuthenticationUrl = $AuthenticationUrl 57 | $this.Type = 'ClientSecret' 58 | } 59 | 60 | EntraToken([string]$Service, [string]$ClientID, [string]$TenantID, [System.Security.Cryptography.X509Certificates.X509Certificate2]$Certificate, [string]$ServiceUrl, [string]$AuthenticationUrl) { 61 | $this.Service = $Service 62 | $this.ClientID = $ClientID 63 | $this.TenantID = $TenantID 64 | $this.Certificate = $Certificate 65 | $this.ServiceUrl = $ServiceUrl 66 | $this.AuthenticationUrl = $AuthenticationUrl 67 | $this.Type = 'Certificate' 68 | } 69 | 70 | EntraToken([string]$Service, [string]$ClientID, [string]$TenantID, [pscredential]$Credential, [string]$ServiceUrl, [string]$AuthenticationUrl) { 71 | $this.Service = $Service 72 | $this.ClientID = $ClientID 73 | $this.TenantID = $TenantID 74 | $this.Credential = $Credential 75 | $this.ServiceUrl = $ServiceUrl 76 | $this.AuthenticationUrl = $AuthenticationUrl 77 | $this.Type = 'UsernamePassword' 78 | } 79 | 80 | EntraToken([string]$Service, [string]$ClientID, [string]$TenantID, [string]$ServiceUrl, [bool]$IsDeviceCode, [string]$AuthenticationUrl) { 81 | $this.Service = $Service 82 | $this.ClientID = $ClientID 83 | $this.TenantID = $TenantID 84 | $this.ServiceUrl = $ServiceUrl 85 | $this.AuthenticationUrl = $AuthenticationUrl 86 | if ($IsDeviceCode) { $this.Type = 'DeviceCode' } 87 | else { $this.Type = 'Browser' } 88 | } 89 | 90 | EntraToken([string]$Service, [string]$ClientID, [string]$TenantID, [string]$ServiceUrl, [string]$VaultName, [string]$SecretName, [string]$AuthenticationUrl) { 91 | $this.Service = $Service 92 | $this.ClientID = $ClientID 93 | $this.TenantID = $TenantID 94 | $this.ServiceUrl = $ServiceUrl 95 | $this.VaultName = $VaultName 96 | $this.SecretName = $SecretName 97 | $this.AuthenticationUrl = $AuthenticationUrl 98 | $this.Type = 'KeyVault' 99 | } 100 | 101 | EntraToken([string]$Service, [string]$ServiceUrl, [string]$IdentityID, [string]$IdentityType) { 102 | $this.Service = $Service 103 | $this.ServiceUrl = $ServiceUrl 104 | $this.Type = 'Identity' 105 | 106 | if ($IdentityID) { 107 | $this.IdentityID = $IdentityID 108 | $this.IdentityType = $IdentityType 109 | } 110 | } 111 | 112 | EntraToken([string]$Service, [string]$ServiceUrl, [string]$ShowDialog) { 113 | $this.Service = $Service 114 | $this.ServiceUrl = $ServiceUrl 115 | $this.ShowDialog = $ShowDialog 116 | $this.Type = 'AzAccount' 117 | } 118 | 119 | # Empty Constructor for Import-EntraToken 120 | EntraToken() {} 121 | #endregion Constructors 122 | 123 | [void]SetTokenMetadata([PSObject] $AuthToken) { 124 | $this.AccessToken = $AuthToken.AccessToken 125 | $this.ValidAfter = $AuthToken.ValidAfter 126 | $this.ValidUntil = $AuthToken.ValidUntil 127 | $this.Scopes = $AuthToken.Scopes 128 | if ($AuthToken.RefreshToken) { $this.RefreshToken = $AuthToken.RefreshToken } 129 | 130 | $tokenPayload = $AuthToken.AccessToken.Split(".")[1].Replace('-', '+').Replace('_', '/') 131 | while ($tokenPayload.Length % 4) { $tokenPayload += "=" } 132 | $bytes = [System.Convert]::FromBase64String($tokenPayload) 133 | $localData = [System.Text.Encoding]::ASCII.GetString($bytes) | ConvertFrom-Json 134 | 135 | if ($localData.roles) { $this.Scopes = $localData.roles } 136 | elseif ($localData.scp) { $this.Scopes = $localData.scp -split " " } 137 | 138 | $this.Audience = $localData.aud 139 | $this.Issuer = $localData.iss 140 | $this.TokenData = $localData 141 | $this.TenantID = $localData.tid 142 | } 143 | 144 | [hashtable]GetHeader() { 145 | if ($this.HeaderCode) { 146 | $newHeader = $this.Header.Clone() 147 | $results = @(& $this.HeaderCode $this)[0] 148 | foreach ($pair in $results.GetEnumerator()) { 149 | $newHeader[$pair.Key] = $pair.Value 150 | } 151 | return $newHeader 152 | } 153 | 154 | if ($this.ValidUntil -lt (Get-Date).AddMinutes(5)) { 155 | $this.RenewToken() 156 | } 157 | 158 | $currentHeader = @{} 159 | if ($this.Header.Count -gt 0) { 160 | $currentHeader = $this.Header.Clone() 161 | } 162 | $currentHeader.Authorization = "Bearer $($this.AccessToken)" 163 | 164 | return $currentHeader 165 | } 166 | 167 | [void]RenewToken() { 168 | $defaultParam = @{ 169 | TenantID = $this.TenantID 170 | ClientID = $this.ClientID 171 | Resource = $this.Audience 172 | AuthenticationUrl = $this.AuthenticationUrl 173 | } 174 | switch ($this.Type) { 175 | Certificate { 176 | $result = Connect-ServiceCertificate @defaultParam -Certificate $this.Certificate 177 | $this.SetTokenMetadata($result) 178 | } 179 | ClientSecret { 180 | $result = Connect-ServiceClientSecret @defaultParam -ClientSecret $this.ClientSecret 181 | $this.SetTokenMetadata($result) 182 | } 183 | UsernamePassword { 184 | $result = Connect-ServicePassword @defaultParam -Credential $this.Credential 185 | $this.SetTokenMetadata($result) 186 | } 187 | DeviceCode { 188 | if ($this.RefreshToken) { 189 | Connect-ServiceRefreshToken -Token $this 190 | return 191 | } 192 | 193 | $result = Connect-ServiceDeviceCode @defaultParam 194 | $this.SetTokenMetadata($result) 195 | } 196 | Browser { 197 | if ($this.RefreshToken) { 198 | Connect-ServiceRefreshToken -Token $this 199 | return 200 | } 201 | 202 | $result = Connect-ServiceBrowser @defaultParam -SelectAccount 203 | $this.SetTokenMetadata($result) 204 | } 205 | Refresh { 206 | Connect-ServiceRefreshToken -Token $this 207 | } 208 | KeyVault { 209 | $secret = Get-VaultSecret -VaultName $this.VaultName -SecretName $this.SecretName 210 | $result = switch ($secret.Type) { 211 | Certificate { Connect-ServiceCertificate @defaultParam -Certificate $secret.Certificate } 212 | ClientSecret { Connect-ServiceClientSecret @defaultParam -ClientSecret $secret.ClientSecret } 213 | } 214 | $this.SetTokenMetadata($result) 215 | } 216 | Identity { 217 | $result = Connect-ServiceIdentity -Resource $this.Audience -IdentityID $this.IdentityID -IdentityType $this.IdentityType 218 | $this.SetTokenMetadata($result) 219 | } 220 | AzAccount { 221 | $result = Connect-ServiceAzure -Resource $this.Audience -ShowDialog $this.ShowDialog 222 | $this.SetTokenMetadata($result) 223 | } 224 | } 225 | } 226 | } -------------------------------------------------------------------------------- /EntraAuth/internal/classes/Environment.ps1: -------------------------------------------------------------------------------- 1 | enum Environment { 2 | Global = 1 3 | USGov = 2 4 | USGovDOD = 3 5 | China = 4 6 | } -------------------------------------------------------------------------------- /EntraAuth/internal/classes/FilterBuilder.ps1: -------------------------------------------------------------------------------- 1 | class FilterBuilder { 2 | [System.Collections.ArrayList]$Entries = @() 3 | [System.Collections.ArrayList]$CustomFilter = @() 4 | 5 | [ValidateSet('AND','OR')][string]$Logic = 'AND' 6 | 7 | FilterBuilder() { } 8 | FilterBuilder([string]$Logic) { 9 | $this.Logic = $Logic 10 | } 11 | 12 | [void]Add([string]$Property, [string]$Operator, $Value) { 13 | $this.Add($Property, $Operator, $Value, $false) 14 | } 15 | [void]Add([string]$Property, [string]$Operator, $Value, [bool]$NoQuotes) { 16 | $null = $this.Entries.Add( 17 | @{ 18 | Property = $Property 19 | Operator = $Operator 20 | Value = $Value 21 | NoQuotes = $NoQuotes 22 | } 23 | ) 24 | } 25 | [void]Add([string]$CustomFilter) { 26 | $null = $this.Entries.Add($CustomFilter) 27 | } 28 | [void]Add([FilterBuilder]$NestedFilter) { 29 | $null = $this.Entries.Add($NestedFilter) 30 | } 31 | 32 | [int]Count() { 33 | $myCount = $this.Entries.Count 34 | if ($this.CustomFilter) { $myCount += $this.CustomFilter.Count } 35 | return $myCount 36 | } 37 | [string]GetHelp() { 38 | return @' 39 | OData Filter Builder Guidance 40 | 41 | This tool _mostly_ maps / implements the OData filter system, as adapted by the Microsoft Graph API. 42 | It may be relevant to any other API supporting OData filters, but that's what it was built for. 43 | 44 | Filter Docs: 45 | https://learn.microsoft.com/en-us/graph/filter-query-parameter 46 | 47 | Adding Filter Conditions: 48 | There are two ways to provide filter conditions: 49 | 50 | A) .Add(property, operator, value) 51 | The .Add method adds individual filter clauses, simple comparisons or some special behaviors. 52 | For these special rules, see below. 53 | But any operator that has a simple " " from this list should be valid: 54 | https://learn.microsoft.com/en-us/graph/filter-query-parameter?tabs=http#operators-and-functions-supported-in-filter-expressions 55 | Strings need not be provided in quotes. 56 | 57 | B) .Add(customFilter) 58 | Specifying a single string allows providing custom filter terms/expressions. 59 | This can be any valid fragment of OData filter, giving you greater control ... but requires you to write the filter yourself. 60 | 61 | All conditions from A) and B) are finally combined with an AND condition. 62 | 63 | 64 | Special Rules: 65 | 66 | eq 67 | The "eq" Operator is converted into a 'startswith' or 'endswith' operator, depending on wildcard use. 68 | 69 | leq 70 | The "leq" Operator is not an actual OData operator, but was added by this tool. 71 | It means "Literal Equals" and is converted into an "eq" operator, but without automatic conversion to 'startswith' or 'endswith'. 72 | 73 | any 74 | The "any" Operator is the OData equivalent to the PowerShell -contains. 75 | 76 | none 77 | The "none" fake-Operator is the OData equivalent to the PowerShell -notcontains. 78 | It translates into an OData all(...) logic with the "ne" operator applied. 79 | '@ 80 | } 81 | [string]Get() { 82 | $segments = :entries foreach ($entry in $this.Entries) { 83 | # Nested Filters 84 | if ($entry -is [FilterBuilder]) { 85 | '(' + $entry.Get() + ')' 86 | continue 87 | } 88 | # Custom Filters 89 | if ($entry -is [string]) { 90 | $entry 91 | continue 92 | } 93 | 94 | 95 | $quotes = "'" 96 | if ($entry.NoQuotes) { $quotes = "" } 97 | 98 | $valueString = $entry.Value -as [string] 99 | if ($null -eq $entry.Value) { $valueString = "null" } 100 | if ( 101 | $entry.Value -is [string] -or 102 | $entry.Value -is [guid] 103 | ) { 104 | $valueString = "$($quotes)$($entry.Value)$($quotes)" 105 | } 106 | if ($entry.Value -is [DateTime]) { 107 | $valueString = $entry.Value.ToString('u') -replace ' ', 'T' 108 | } 109 | 110 | switch ($entry.Operator) { 111 | 'eq' { 112 | # Case: eq with Wildcard 113 | if ($entry.Value -match '\*$' -and $entry.Operator -eq 'eq') { 114 | "startswith($($entry.Property), $($quotes)$($entry.Value.TrimEnd('*'))$($quotes))" 115 | continue entries 116 | } 117 | if ($entry.Value -match '^\*' -and $entry.Operator -eq 'eq') { 118 | "endswith($($entry.Property), $($quotes)$($entry.Value.TrimStart('*'))$($quotes))" 119 | continue entries 120 | } 121 | '{0} eq {1}' -f $entry.Property, $valueString 122 | } 123 | 'leq' { 124 | '{0} eq {1}' -f $entry.Property, $valueString 125 | } 126 | 'in' { 127 | '{0} in ({1})' -f $entry.Property, (@($entry.Value).ForEach{ "$($quotes)$_$($quotes)" } -join ', ') 128 | } 129 | 'any' { 130 | '{0}/any(x:x eq {1})' -f $entry.Property, $valueString 131 | } 132 | 'none' { 133 | '{0}/all(x:x ne {1})' -f $entry.Property, $valueString 134 | } 135 | default { 136 | '{0} {1} {2}' -f $entry.Property, $entry.Operator, $valueString 137 | } 138 | } 139 | } 140 | if ($this.CustomFilter) { 141 | if ($segments) { $segments = @($segments) + $this.CustomFilter } 142 | else { $segments = $this.CustomFilter } 143 | } 144 | 145 | if ($this.Logic -eq 'OR') { return $segments -join ' or ' } 146 | return $segments -join ' and ' 147 | } 148 | [hashtable]GetHeader() { 149 | return @{ '$filter' = $this.Get() } 150 | } 151 | } -------------------------------------------------------------------------------- /EntraAuth/internal/classes/ServiceSelector.ps1: -------------------------------------------------------------------------------- 1 | class ServiceSelector { 2 | [hashtable]$DefaultServices = @{ } 3 | 4 | ServiceSelector([Hashtable]$Services) { 5 | $this.DefaultServices = $Services 6 | } 7 | 8 | [string]GetService([hashtable]$ServiceMap, [string]$Name) { 9 | if ($ServiceMap[$Name]) { return $ServiceMap[$Name] } 10 | 11 | return $this.DefaultServices[$Name] 12 | } 13 | [hashtable]GetServiceMap([hashtable]$ServiceMap) { 14 | $map = $this.DefaultServices.Clone() 15 | if ($ServiceMap) { 16 | foreach ($pair in $ServiceMap.GetEnumerator()) { 17 | $map[$pair.Key] = $pair.Value 18 | } 19 | } 20 | return $map 21 | } 22 | } -------------------------------------------------------------------------------- /EntraAuth/internal/functions/authentication/Connect-ServiceAzure.ps1: -------------------------------------------------------------------------------- 1 | function Connect-ServiceAzure { 2 | <# 3 | .SYNOPSIS 4 | Authenticates using the established session from Az.Accounts. 5 | 6 | .DESCRIPTION 7 | Authenticates using the established session from Az.Accounts. 8 | This limits the scopes available to what is configured on the Az Application, but makes it easy to authenticate without active interaction. 9 | 10 | Pretty useful for authenticating to custom apps that do not actually implement scopes. 11 | 12 | .PARAMETER Resource 13 | The resource owning the api permissions / scopes requested. 14 | 15 | .PARAMETER ShowDialog 16 | Whether to show a dialog in case of interaction being needed. 17 | Defaults to: auto 18 | 19 | .EXAMPLE 20 | PS C:\> Connect-ServiceAzure -Resource 'https://graph.microsoft.com' 21 | 22 | Connect to graph using the existing az.accounts session. 23 | #> 24 | [CmdletBinding()] 25 | param ( 26 | [Parameter(Mandatory = $true)] 27 | [string] 28 | $Resource, 29 | 30 | [ValidateSet('Auto', 'Always', 'Never')] 31 | [string] 32 | $ShowDialog = 'Auto' 33 | ) 34 | process { 35 | try { $azContext = Get-AzContext -ErrorAction Stop } 36 | catch { Invoke-TerminatingException -Cmdlet $PSCmdlet -Message 'Error accessing azure context. Ensure the module "Az.Accounts" is installed and you have connected via "Connect-AzAccount"!' -ErrorRecord $_ } 37 | 38 | try { 39 | $result = [Microsoft.Azure.Commands.Common.Authentication.AzureSession]::Instance.AuthenticationFactory.Authenticate( 40 | $azContext.Account, 41 | $azContext.Environment, 42 | "$($azContext.Tenant.id)", 43 | $null, 44 | $ShowDialog, 45 | $null, 46 | $Resource 47 | ) 48 | 49 | } 50 | catch { Invoke-TerminatingException -Cmdlet $PSCmdlet -Message "Error retrieving token from Azure for '$Resource': $_" -ErrorRecord $_ } 51 | 52 | $tokenData = Read-TokenData -Token $result.AccessToken 53 | 54 | # A Fake AuthResponse result - Should keep the same layout as the result of Read-AuthResponse 55 | [PSCustomObject]@{ 56 | AccessToken = $result.AccessToken 57 | ValidAfter = Get-Date 58 | ValidUntil = $result.ExpiresOn.LocalDateTime 59 | Scopes = $tokenData.scp -split ' ' 60 | RefreshToken = $null 61 | 62 | # For Initial Connect Metadata 63 | ClientID = $tokenData.appid 64 | TenantID = $tokenData.tid 65 | } 66 | } 67 | } -------------------------------------------------------------------------------- /EntraAuth/internal/functions/authentication/Connect-ServiceBrowser.ps1: -------------------------------------------------------------------------------- 1 | function Connect-ServiceBrowser { 2 | <# 3 | .SYNOPSIS 4 | Interactive logon using the Authorization flow and browser. Supports SSO. 5 | 6 | .DESCRIPTION 7 | Interactive logon using the Authorization flow and browser. Supports SSO. 8 | 9 | This flow requires an App Registration configured for the platform "Mobile and desktop applications". 10 | Its redirect Uri must be "http://localhost" 11 | 12 | On successful authentication 13 | 14 | .PARAMETER ClientID 15 | The ID of the registered app used with this authentication request. 16 | 17 | .PARAMETER TenantID 18 | The ID of the tenant connected to with this authentication request. 19 | 20 | .PARAMETER SelectAccount 21 | Forces account selection on logon. 22 | As this flow supports single-sign-on, it will otherwise not prompt for anything if already signed in. 23 | This could be a problem if you want to connect using another (e.g. an admin) account. 24 | 25 | .PARAMETER Scopes 26 | Generally doesn't need to be changed from the default '.default' 27 | 28 | .PARAMETER LocalPort 29 | The local port that should be redirected to. 30 | In order to process the authentication response, we need to listen to a local web request on some port. 31 | Usually needs not be redirected. 32 | Defaults to: 8080 33 | 34 | .PARAMETER Resource 35 | The resource owning the api permissions / scopes requested. 36 | 37 | .PARAMETER Browser 38 | The path to the browser to use for the authentication flow. 39 | Provide the full path to the executable. 40 | The browser must accept the url to open as its only parameter. 41 | Defaults to your default browser. 42 | 43 | .PARAMETER BrowserMode 44 | How the browser used for authentication is selected. 45 | Options: 46 | + Auto (default): Automatically use the default browser. 47 | + PrintLink: The link to open is printed on console and user selects which browser to paste it into (must be used on the same machine) 48 | 49 | .PARAMETER NoReconnect 50 | Disables automatic reconnection. 51 | By default, this module will automatically try to reaquire a new token before the old one expires. 52 | 53 | .PARAMETER AuthenticationUrl 54 | The url used for the authentication requests to retrieve tokens. 55 | 56 | .EXAMPLE 57 | PS C:\> Connect-ServiceBrowser -ClientID '' -TenantID '' 58 | 59 | Connects to the specified tenant using the specified client, prompting the user to authorize via Browser. 60 | #> 61 | [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidUsingWriteHost", "")] 62 | [CmdletBinding()] 63 | param ( 64 | [Parameter(Mandatory = $true)] 65 | [string] 66 | $TenantID, 67 | 68 | [Parameter(Mandatory = $true)] 69 | [string] 70 | $ClientID, 71 | 72 | [Parameter(Mandatory = $true)] 73 | [string] 74 | $Resource, 75 | 76 | [switch] 77 | $SelectAccount, 78 | 79 | [AllowEmptyCollection()] 80 | [string[]] 81 | $Scopes, 82 | 83 | [int] 84 | $LocalPort = 8080, 85 | 86 | [string] 87 | $Browser, 88 | 89 | [Parameter(ParameterSetName = 'Browser')] 90 | [ValidateSet('Auto', 'PrintLink')] 91 | [string] 92 | $BrowserMode = 'Auto', 93 | 94 | [switch] 95 | $NoReconnect, 96 | 97 | [Parameter(Mandatory = $true)] 98 | [string] 99 | $AuthenticationUrl 100 | ) 101 | process { 102 | Add-Type -AssemblyName System.Web 103 | if (-not $Scopes) { $Scopes = @('.default') } 104 | 105 | $redirectUri = "http://localhost:$LocalPort" 106 | $actualScopes = $Scopes | Resolve-ScopeName -Resource $Resource 107 | 108 | if (-not $NoReconnect) { 109 | $actualScopes = @($actualScopes) + 'offline_access' 110 | } 111 | 112 | $uri = "$AuthenticationUrl/$TenantID/oauth2/v2.0/authorize?" 113 | $state = Get-Random 114 | $parameters = @{ 115 | client_id = $ClientID 116 | response_type = 'code' 117 | redirect_uri = $redirectUri 118 | response_mode = 'query' 119 | scope = $actualScopes -join ' ' 120 | state = $state 121 | } 122 | if ($SelectAccount) { 123 | $parameters.prompt = 'select_account' 124 | } 125 | 126 | $paramStrings = foreach ($pair in $parameters.GetEnumerator()) { 127 | $pair.Key, ([System.Web.HttpUtility]::UrlEncode($pair.Value)) -join '=' 128 | } 129 | $uriFinal = $uri + ($paramStrings -join '&') 130 | Write-Verbose "Authorize Uri: $uriFinal" 131 | 132 | $redirectTo = 'https://raw.githubusercontent.com/FriedrichWeinmann/MiniGraph/master/nothing-to-see-here.txt' 133 | if ((Get-Random -Minimum 10 -Maximum 99) -eq 66) { 134 | $redirectTo = 'https://www.youtube.com/watch?v=dQw4w9WgXcQ' 135 | } 136 | 137 | # Start local server to catch the redirect 138 | $http = [System.Net.HttpListener]::new() 139 | $http.Prefixes.Add("$redirectUri/") 140 | try { $http.Start() } 141 | catch { Invoke-TerminatingException -Cmdlet $PSCmdlet -Message "Failed to create local http listener on port $LocalPort. Use -LocalPort to select a different port. $_" -Category OpenError } 142 | 143 | switch ($BrowserMode) { 144 | Auto { 145 | # Execute in default browser 146 | if ($Browser) { & $Browser $uriFinal } 147 | else { Start-Process $uriFinal } 148 | } 149 | PrintLink { 150 | Write-Host @" 151 | Ready to authenticate. Paste the following link into the browser of your choice on the local computer: 152 | $uriFinal 153 | "@ 154 | } 155 | } 156 | 157 | # Get Result 158 | $task = $http.GetContextAsync() 159 | $authorizationCode, $stateReturn, $sessionState = $null 160 | try { 161 | while (-not $task.IsCompleted) { 162 | Start-Sleep -Milliseconds 200 163 | } 164 | $context = $task.Result 165 | $context.Response.Redirect($redirectTo) 166 | $context.Response.Close() 167 | $authorizationCode, $stateReturn, $sessionState = $context.Request.Url.Query -split "&" 168 | } 169 | finally { 170 | $http.Stop() 171 | $http.Dispose() 172 | } 173 | 174 | if (-not $stateReturn) { 175 | Invoke-TerminatingException -Cmdlet $PSCmdlet -Message "Authentication failed (see browser for details)" -Category AuthenticationError 176 | } 177 | 178 | if ($stateReturn -match '^error_description=') { 179 | $message = $stateReturn -replace '^error_description=' -replace '\+',' ' 180 | $message = [System.Web.HttpUtility]::UrlDecode($message) 181 | Invoke-TerminatingException -Cmdlet $PSCmdlet -Message "Error processing the request: $message" -Category InvalidOperation 182 | } 183 | 184 | if ($state -ne $stateReturn.Split("=")[1]) { 185 | Invoke-TerminatingException -Cmdlet $PSCmdlet -Message "Received invalid authentication result. Likely returned from another flow redirecting to the same local port!" -Category InvalidOperation 186 | } 187 | 188 | $actualAuthorizationCode = $authorizationCode.Split("=")[1] 189 | 190 | $body = @{ 191 | client_id = $ClientID 192 | scope = $actualScopes -join " " 193 | code = $actualAuthorizationCode 194 | redirect_uri = $redirectUri 195 | grant_type = 'authorization_code' 196 | } 197 | $uri = "$AuthenticationUrl/$TenantID/oauth2/v2.0/token" 198 | try { $authResponse = Invoke-RestMethod -Method Post -Uri $uri -Body $body -ErrorAction Stop } 199 | catch { 200 | if ($_ -notmatch '"error":\s*"invalid_client"') { Invoke-TerminatingException -Cmdlet $PSCmdlet -ErrorRecord $_ } 201 | Invoke-TerminatingException -Cmdlet $PSCmdlet -Message "The App Registration $ClientID has not been configured correctly. Ensure you have a 'Mobile and desktop applications' platform with redirect to 'http://localhost' configured (and not a 'Web' Platform). $_" -Category $_.CategoryInfo.Category 202 | } 203 | Read-AuthResponse -AuthResponse $authResponse 204 | } 205 | } -------------------------------------------------------------------------------- /EntraAuth/internal/functions/authentication/Connect-ServiceCertificate.ps1: -------------------------------------------------------------------------------- 1 | function Connect-ServiceCertificate { 2 | <# 3 | .SYNOPSIS 4 | Connects to AAD using a application ID and a certificate. 5 | 6 | .DESCRIPTION 7 | Connects to AAD using a application ID and a certificate. 8 | 9 | .PARAMETER Resource 10 | The resource owning the api permissions / scopes requested. 11 | 12 | .PARAMETER Certificate 13 | The certificate to use for authentication. 14 | 15 | .PARAMETER TenantID 16 | The ID of the tenant/directory to connect to. 17 | 18 | .PARAMETER ClientID 19 | The ID of the registered application used to authenticate as. 20 | 21 | .PARAMETER AuthenticationUrl 22 | The url used for the authentication requests to retrieve tokens. 23 | 24 | .EXAMPLE 25 | PS C:\> Connect-ServiceCertificate -Certificate $cert -TenantID $tenantID -ClientID $clientID 26 | 27 | Connects to the specified tenant using the specified app & cert. 28 | 29 | .LINK 30 | https://docs.microsoft.com/en-us/azure/active-directory/develop/active-directory-certificate-credentials 31 | #> 32 | [CmdletBinding()] 33 | param ( 34 | [Parameter(Mandatory = $true)] 35 | [string] 36 | $Resource, 37 | 38 | [Parameter(Mandatory = $true)] 39 | [System.Security.Cryptography.X509Certificates.X509Certificate2] 40 | $Certificate, 41 | 42 | [Parameter(Mandatory = $true)] 43 | [string] 44 | $TenantID, 45 | 46 | [Parameter(Mandatory = $true)] 47 | [string] 48 | $ClientID, 49 | 50 | [Parameter(Mandatory = $true)] 51 | [string] 52 | $AuthenticationUrl 53 | ) 54 | 55 | #region Build Signature Payload 56 | $jwtHeader = @{ 57 | alg = "RS256" 58 | typ = "JWT" 59 | x5t = [Convert]::ToBase64String($Certificate.GetCertHash()) -replace '\+', '-' -replace '/', '_' -replace '=' 60 | } 61 | $encodedHeader = $jwtHeader | ConvertTo-Json | ConvertTo-Base64 62 | $claims = @{ 63 | aud = "$AuthenticationUrl/$TenantID/v2.0" 64 | exp = ((Get-Date).AddMinutes(5).ToUniversalTime() - (Get-Date -Date '1970-01-01')).TotalSeconds -as [int] 65 | iss = $ClientID 66 | jti = "$(New-Guid)" 67 | nbf = ((Get-Date).ToUniversalTime() - (Get-Date -Date '1970-01-01')).TotalSeconds -as [int] 68 | sub = $ClientID 69 | } 70 | $encodedClaims = $claims | ConvertTo-Json | ConvertTo-Base64 71 | $jwtPreliminary = $encodedHeader, $encodedClaims -join "." 72 | $jwtSigned = ($jwtPreliminary | ConvertTo-SignedString -Certificate $Certificate) -replace '\+', '-' -replace '/', '_' -replace '=' 73 | $jwt = $jwtPreliminary, $jwtSigned -join '.' 74 | #endregion Build Signature Payload 75 | 76 | $body = @{ 77 | client_id = $ClientID 78 | client_assertion = $jwt 79 | client_assertion_type = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer' 80 | scope = '{0}/.default' -f $Resource 81 | grant_type = 'client_credentials' 82 | } 83 | $header = @{ 84 | Authorization = "Bearer $jwt" 85 | } 86 | $uri = "$AuthenticationUrl/$TenantID/oauth2/v2.0/token" 87 | 88 | try { $authResponse = Invoke-RestMethod -Method Post -Uri $uri -Body $body -Headers $header -ContentType 'application/x-www-form-urlencoded' -ErrorAction Stop } 89 | catch { throw } 90 | 91 | Read-AuthResponse -AuthResponse $authResponse 92 | } -------------------------------------------------------------------------------- /EntraAuth/internal/functions/authentication/Connect-ServiceClientSecret.ps1: -------------------------------------------------------------------------------- 1 | function Connect-ServiceClientSecret { 2 | <# 3 | .SYNOPSIS 4 | Connets using a client secret. 5 | 6 | .DESCRIPTION 7 | Connets using a client secret. 8 | 9 | .PARAMETER Resource 10 | The resource owning the api permissions / scopes requested. 11 | 12 | .PARAMETER ClientID 13 | The ID of the registered app used with this authentication request. 14 | 15 | .PARAMETER TenantID 16 | The ID of the tenant connected to with this authentication request. 17 | 18 | .PARAMETER ClientSecret 19 | The actual secret used for authenticating the request. 20 | 21 | .PARAMETER AuthenticationUrl 22 | The url used for the authentication requests to retrieve tokens. 23 | 24 | .EXAMPLE 25 | PS C:\> Connect-ServiceClientSecret -ClientID '' -TenantID '' -ClientSecret $secret 26 | 27 | Connects to the specified tenant using the specified client and secret. 28 | #> 29 | [CmdletBinding()] 30 | param ( 31 | [Parameter(Mandatory = $true)] 32 | [string] 33 | $Resource, 34 | 35 | [Parameter(Mandatory = $true)] 36 | [string] 37 | $ClientID, 38 | 39 | [Parameter(Mandatory = $true)] 40 | [string] 41 | $TenantID, 42 | 43 | [Parameter(Mandatory = $true)] 44 | [securestring] 45 | $ClientSecret, 46 | 47 | [Parameter(Mandatory = $true)] 48 | [string] 49 | $AuthenticationUrl 50 | ) 51 | 52 | process { 53 | $body = @{ 54 | resource = $Resource 55 | client_id = $ClientID 56 | client_secret = [PSCredential]::new('NoMatter', $ClientSecret).GetNetworkCredential().Password 57 | grant_type = 'client_credentials' 58 | } 59 | try { $authResponse = Invoke-RestMethod -Method Post -Uri "$AuthenticationUrl/$TenantId/oauth2/token" -Body $body -ErrorAction Stop } 60 | catch { throw } 61 | 62 | Read-AuthResponse -AuthResponse $authResponse 63 | } 64 | } -------------------------------------------------------------------------------- /EntraAuth/internal/functions/authentication/Connect-ServiceDeviceCode.ps1: -------------------------------------------------------------------------------- 1 | function Connect-ServiceDeviceCode { 2 | <# 3 | .SYNOPSIS 4 | Connects to Azure AD using the Device Code authentication workflow. 5 | 6 | .DESCRIPTION 7 | Connects to Azure AD using the Device Code authentication workflow. 8 | 9 | .PARAMETER Resource 10 | The resource owning the api permissions / scopes requested. 11 | 12 | .PARAMETER ClientID 13 | The ID of the registered app used with this authentication request. 14 | 15 | .PARAMETER TenantID 16 | The ID of the tenant connected to with this authentication request. 17 | 18 | .PARAMETER Scopes 19 | The scopes to request. 20 | Automatically scoped to the service specified via Service Url. 21 | Defaults to ".Default" 22 | 23 | .PARAMETER NoReconnect 24 | Disables automatic reconnection. 25 | By default, this module will automatically try to reaquire a new token before the old one expires. 26 | 27 | .PARAMETER AuthenticationUrl 28 | The url used for the authentication requests to retrieve tokens. 29 | 30 | .EXAMPLE 31 | PS C:\> Connect-ServiceDeviceCode -ServiceUrl $url -ClientID '' -TenantID '' 32 | 33 | Connects to the specified tenant using the specified client, prompting the user to authorize via Browser. 34 | #> 35 | [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidUsingWriteHost", "")] 36 | [CmdletBinding()] 37 | param ( 38 | [Parameter(Mandatory = $true)] 39 | [string] 40 | $Resource, 41 | 42 | [Parameter(Mandatory = $true)] 43 | [string] 44 | $ClientID, 45 | 46 | [Parameter(Mandatory = $true)] 47 | [string] 48 | $TenantID, 49 | 50 | [AllowEmptyCollection()] 51 | [string[]] 52 | $Scopes, 53 | 54 | [switch] 55 | $NoReconnect, 56 | 57 | [Parameter(Mandatory = $true)] 58 | [string] 59 | $AuthenticationUrl 60 | ) 61 | 62 | if (-not $Scopes) { $Scopes = @('.default') } 63 | $actualScopes = $Scopes | Resolve-ScopeName -Resource $Resource 64 | 65 | if (-not $NoReconnect) { 66 | $actualScopes = @($actualScopes) + 'offline_access' 67 | } 68 | 69 | try { 70 | $initialResponse = Invoke-RestMethod -Method POST -Uri "$AuthenticationUrl/$TenantID/oauth2/v2.0/devicecode" -Body @{ 71 | client_id = $ClientID 72 | scope = $actualScopes -join " " 73 | } -ErrorAction Stop 74 | } 75 | catch { 76 | throw 77 | } 78 | 79 | Write-Host $initialResponse.message 80 | 81 | $paramRetrieve = @{ 82 | Uri = "$AuthenticationUrl/$TenantID/oauth2/v2.0/token" 83 | Method = "POST" 84 | Body = @{ 85 | grant_type = "urn:ietf:params:oauth:grant-type:device_code" 86 | client_id = $ClientID 87 | device_code = $initialResponse.device_code 88 | } 89 | ErrorAction = 'Stop' 90 | } 91 | $limit = (Get-Date).AddSeconds($initialResponse.expires_in) 92 | while ($true) { 93 | if ((Get-Date) -gt $limit) { 94 | Invoke-TerminatingException -Cmdlet $PSCmdlet -Message "Timelimit exceeded, device code authentication failed" -Category AuthenticationError 95 | } 96 | Start-Sleep -Seconds $initialResponse.interval 97 | try { $authResponse = Invoke-RestMethod @paramRetrieve } 98 | catch { 99 | if ($_ -match '"error":\s*"authorization_pending"') { continue } 100 | $PSCmdlet.ThrowTerminatingError($_) 101 | } 102 | if ($authResponse) { 103 | break 104 | } 105 | } 106 | 107 | Read-AuthResponse -AuthResponse $authResponse 108 | } -------------------------------------------------------------------------------- /EntraAuth/internal/functions/authentication/Connect-ServiceIdentity.ps1: -------------------------------------------------------------------------------- 1 | function Connect-ServiceIdentity { 2 | <# 3 | .SYNOPSIS 4 | Connect as the current Managed Identity. 5 | 6 | .DESCRIPTION 7 | Connect as the current Managed Identity. 8 | Only works from within the context of a managed environment, such as Azure Functions with enabled MSI. 9 | 10 | .PARAMETER Resource 11 | The resource to get a token for. 12 | 13 | .PARAMETER IdentityID 14 | ID of the User-Managed Identity to connect as. 15 | 16 | .PARAMETER IdentityType 17 | Type of the User-Managed Identity. 18 | 19 | .PARAMETER Cmdlet 20 | The $PSCmdlet of the calling command. 21 | If specified, errors are triggered in the caller's context. 22 | 23 | .EXAMPLE 24 | PS C:\> Connect-ServiceIdentity -Resource 'https://vault.azure.net' 25 | 26 | Connect as the current managed identity, retrieving a token for the Azure Key Vault. 27 | 28 | .LINK 29 | https://learn.microsoft.com/en-us/azure/app-service/overview-managed-identity 30 | #> 31 | [CmdletBinding()] 32 | param ( 33 | [Parameter(Mandatory = $true)] 34 | [string] 35 | $Resource, 36 | 37 | [AllowEmptyString()] 38 | [AllowNull()] 39 | [string] 40 | $IdentityID, 41 | 42 | [AllowEmptyString()] 43 | [AllowNull()] 44 | [string] 45 | $IdentityType, 46 | 47 | $Cmdlet = $PSCmdlet 48 | ) 49 | process { 50 | # Logic for Azure VMs 51 | try { 52 | $vmMetadata = $null 53 | $vmMetadata = Invoke-RestMethod -Headers @{Metadata = "true" } -Method GET -NoProxy -Uri "http://169.254.169.254/metadata/instance?api-version=2021-02-01" 54 | } 55 | catch { 56 | $vmMetadata = $null 57 | } 58 | if ($vmMetadata.compute.azEnvironment -like "*Azure*") { 59 | Write-Verbose "We are running on an Azure VM. Setting Environment Variables" 60 | $isAzureVM = $true 61 | $env:IDENTITY_ENDPOINT = "http://169.254.169.254/metadata/identity/oauth2/token" 62 | $env:IDENTITY_API_VERSION = "2018-02-01" 63 | } 64 | 65 | if ((-not $env:IDENTITY_ENDPOINT) -or (-not $env:IDENTITY_HEADER)) { 66 | Invoke-TerminatingException -Cmdlet $Cmdlet -Message "Cannot identify a Managed Identity. MSI logon not possible!" -Category ConnectionError 67 | } 68 | 69 | $apiVersion = $env:IDENTITY_API_VERSION 70 | if (-not $apiVersion) { $apiVersion = '2019-08-01' } 71 | 72 | $url = "$($env:IDENTITY_ENDPOINT)?resource=$Resource&api-version=$apiVersion" 73 | if ($IdentityID) { 74 | $labels = @{ 75 | ClientID = 'client_id' 76 | ResourceID = 'mi_res_id' 77 | PrincipalID = 'principal_id' 78 | } 79 | $url = $url + "&$($labels[$IdentityType])=$($IdentityID)" 80 | } 81 | 82 | try { 83 | Write-Verbose "$url" 84 | if ($isAzureVM) { 85 | $headers = @{Metadata = 'true' } 86 | } 87 | else { 88 | $headers = @{'X-IDENTITY-HEADER' = $env:IDENTITY_HEADER } 89 | } 90 | $authResponse = Invoke-RestMethod -Uri $url -Headers $headers -ErrorAction Stop 91 | } 92 | catch { 93 | Invoke-TerminatingException -Cmdlet $Cmdlet -Message "Failed to connect via Managed Identity: $_" -ErrorRecord $_ 94 | } 95 | 96 | Read-AuthResponse -AuthResponse $authResponse 97 | } 98 | } -------------------------------------------------------------------------------- /EntraAuth/internal/functions/authentication/Connect-ServicePassword.ps1: -------------------------------------------------------------------------------- 1 | function Connect-ServicePassword { 2 | <# 3 | .SYNOPSIS 4 | Connect to graph using username and password. 5 | 6 | .DESCRIPTION 7 | Connect to graph using username and password. 8 | This logs into graph as a user, not as an application. 9 | Only cloud-only accounts can be used for this workflow. 10 | Consent to scopes must be granted before using them, as this command cannot show the consent prompt. 11 | 12 | .PARAMETER Resource 13 | The resource owning the api permissions / scopes requested. 14 | 15 | .PARAMETER Credential 16 | Credentials of the user to connect as. 17 | 18 | .PARAMETER TenantID 19 | The Guid of the tenant to connect to. 20 | 21 | .PARAMETER ClientID 22 | The ClientID / ApplicationID of the application to use. 23 | 24 | .PARAMETER Scopes 25 | The permission scopes to request. 26 | 27 | .PARAMETER AuthenticationUrl 28 | The url used for the authentication requests to retrieve tokens. 29 | 30 | .EXAMPLE 31 | PS C:\> Connect-ServicePassword -Credential max@contoso.com -ClientID $client -TenantID $tenant -Scopes 'user.read','user.readbasic.all' 32 | 33 | Connect as max@contoso.com with the rights to read user information. 34 | #> 35 | [CmdletBinding()] 36 | param ( 37 | [Parameter(Mandatory = $true)] 38 | [string] 39 | $Resource, 40 | 41 | [Parameter(Mandatory = $true)] 42 | [System.Management.Automation.PSCredential] 43 | $Credential, 44 | 45 | [Parameter(Mandatory = $true)] 46 | [string] 47 | $ClientID, 48 | 49 | [Parameter(Mandatory = $true)] 50 | [string] 51 | $TenantID, 52 | 53 | [string[]] 54 | $Scopes = '.default', 55 | 56 | [Parameter(Mandatory = $true)] 57 | [string] 58 | $AuthenticationUrl 59 | ) 60 | 61 | $actualScopes = $Scopes | Resolve-ScopeName -Resource $Resource 62 | 63 | $request = @{ 64 | client_id = $ClientID 65 | scope = $actualScopes -join " " 66 | username = $Credential.UserName 67 | password = $Credential.GetNetworkCredential().Password 68 | grant_type = 'password' 69 | } 70 | 71 | try { $authResponse = Invoke-RestMethod -Method POST -Uri "$AuthenticationUrl/$TenantID/oauth2/v2.0/token" -Body $request -ErrorAction Stop } 72 | catch { throw } 73 | 74 | Read-AuthResponse -AuthResponse $authResponse 75 | } -------------------------------------------------------------------------------- /EntraAuth/internal/functions/authentication/Connect-ServiceRefreshToken.ps1: -------------------------------------------------------------------------------- 1 | function Connect-ServiceRefreshToken { 2 | <# 3 | .SYNOPSIS 4 | Connect with the refresh token provided previously. 5 | 6 | .DESCRIPTION 7 | Connect with the refresh token provided previously. 8 | Used mostly for delegate authentication flows to avoid interactivity. 9 | 10 | Can also be resolved to from the outside, when trying to get multiple tokens with a single delegate flow. 11 | 12 | .PARAMETER Token 13 | The EntraToken object with the refresh token to use. 14 | The token is then refreshed in-place with no output provided. 15 | 16 | .PARAMETER RefreshToken 17 | The RefreshToken to use for authenticating. 18 | 19 | .PARAMETER TenantID 20 | ID of the tenant to connect to. 21 | 22 | .PARAMETER ClientID 23 | ID of the application to connect as. 24 | 25 | .PARAMETER Resource 26 | Resource we want the scopes for. 27 | 28 | .PARAMETER Scopes 29 | Scopes we want to use. 30 | 31 | .PARAMETER AuthenticationUrl 32 | The url used for the authentication requests to retrieve tokens. 33 | 34 | .EXAMPLE 35 | PS C:\> Connect-ServiceRefreshToken -Token $token 36 | 37 | Connect with the refresh token provided previously. 38 | #> 39 | [CmdletBinding()] 40 | param ( 41 | [Parameter(Mandatory = $true, ParameterSetName = 'Token')] 42 | $Token, 43 | 44 | [Parameter(Mandatory = $true, ParameterSetName = 'Details')] 45 | [string] 46 | $RefreshToken, 47 | 48 | [Parameter(Mandatory = $true, ParameterSetName = 'Details')] 49 | [string] 50 | $TenantID, 51 | 52 | [Parameter(Mandatory = $true, ParameterSetName = 'Details')] 53 | [string] 54 | $ClientID, 55 | 56 | [Parameter(Mandatory = $true, ParameterSetName = 'Details')] 57 | [string] 58 | $Resource, 59 | 60 | [Parameter(ParameterSetName = 'Details')] 61 | [string[]] 62 | $Scopes = '.default', 63 | 64 | [Parameter(Mandatory = $true, ParameterSetName = 'Details')] 65 | [string] 66 | $AuthenticationUrl 67 | ) 68 | process { 69 | switch ($PSCmdlet.ParameterSetName) { 70 | 'Token' { 71 | if (-not $Token.RefreshToken) { 72 | throw "Failed to refresh token: No refresh token found!" 73 | } 74 | 75 | $effectiveScopes = $Token.Scopes 76 | 77 | $body = @{ 78 | client_id = $Token.ClientID 79 | scope = @($effectiveScopes).ForEach{"$($Token.Audience)/$($_)"} -join " " 80 | refresh_token = $Token.RefreshToken 81 | grant_type = 'refresh_token' 82 | } 83 | $uri = "$($Token.AuthenticationUrl)/$($Token.TenantID)/oauth2/v2.0/token" 84 | $authResponse = Invoke-RestMethod -Method Post -Uri $uri -Body $body 85 | $Token.SetTokenMetadata((Read-AuthResponse -AuthResponse $authResponse)) 86 | } 87 | 'Details' { 88 | $effectiveScopes = foreach ($scope in $Scopes) { 89 | if ($scope -like "$Resource*") { $scope } 90 | else { "$Resource/$scope" } 91 | } 92 | 93 | $body = @{ 94 | client_id = $ClientID 95 | scope = $effectiveScopes -join " " 96 | refresh_token = $RefreshToken 97 | grant_type = 'refresh_token' 98 | } 99 | $uri = "$($AuthenticationUrl)/$($TenantID)/oauth2/v2.0/token" 100 | $authResponse = Invoke-RestMethod -Method Post -Uri $uri -Body $body 101 | Read-AuthResponse -AuthResponse $authResponse 102 | } 103 | } 104 | } 105 | } -------------------------------------------------------------------------------- /EntraAuth/internal/functions/authentication/Get-VaultSecret.ps1: -------------------------------------------------------------------------------- 1 | function Get-VaultSecret { 2 | <# 3 | .SYNOPSIS 4 | Retrieve a secret from Azure Key Vault. 5 | 6 | .DESCRIPTION 7 | Retrieve a secret from Azure Key Vault. 8 | Works for both certificates and secrets. 9 | 10 | Requires one of ... 11 | - An established connection with the AzureKeyVault service. 12 | - An established AZ session via Az.Accounts with the Az.KeyVault module present. 13 | 14 | .PARAMETER VaultName 15 | Name of the Vault to query. 16 | 17 | .PARAMETER SecretName 18 | Name of the Secret to retrieve. 19 | 20 | .PARAMETER Cmdlet 21 | The $PSCmdlet object of the caller, enabling errors to happen within the scope of the caller. 22 | Defaults to the current command's $PSCmdlet 23 | 24 | .EXAMPLE 25 | PS C:\> Get-VaultSecret -VaultName myvault -SecretName mysecret 26 | 27 | Retrieves the latest enabled version of mysecret from myvault 28 | #> 29 | [Diagnostics.CodeAnalysis.SuppressMessageAttribute("PSAvoidUsingConvertToSecureStringWithPlainText", "")] 30 | [CmdletBinding()] 31 | param ( 32 | [Parameter(Mandatory = $true)] 33 | [string] 34 | $VaultName, 35 | 36 | [Parameter(Mandatory = $true)] 37 | [string] 38 | $SecretName, 39 | 40 | $Cmdlet = $PSCmdlet 41 | ) 42 | 43 | process { 44 | #region Via EntraAuth 45 | if (Get-EntraToken -Service AzureKeyVault) { 46 | try { 47 | $secretVersion = Invoke-EntraRequest -Service AzureKeyVault -Path "secrets/$SecretName/versions" -VaultName $VaultName -ErrorAction Stop | Where-Object { 48 | $_.attributes.enabled 49 | } | Sort-Object { $_.attributes.created } -Descending | Select-Object -First 1 50 | 51 | if (-not $secretVersion) { 52 | Invoke-TerminatingException -Cmdlet $Cmdlet -ErrorRecord $_ -Message "Secret '$SecretName' does not exist in vault '$VaultName'! $_" 53 | } 54 | $secretData = Invoke-EntraRequest -Service AzureKeyVault -Path $secretVersion.id -VaultName $VaultName -ErrorAction Stop 55 | } 56 | catch { 57 | Invoke-TerminatingException -Cmdlet $Cmdlet -ErrorRecord $_ -Message "Failed to retrieve secret '$SecretName' from '$VaultName'! $_" 58 | } 59 | 60 | if ($secretVersion.contentType) { 61 | $secretBytes = [convert]::FromBase64String($secretData) 62 | $certificate = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($secretBytes) 63 | [PSCustomObject]@{ 64 | Type = 'Certificate' 65 | Certificate = $certificate 66 | ClientSecret = $null 67 | } 68 | } 69 | else { 70 | [PSCustomObject]@{ 71 | Type = 'ClientSecret' 72 | Certificate = $null 73 | ClientSecret = $secretData | ConvertTo-SecureString -AsPlainText -Force 74 | } 75 | } 76 | 77 | return 78 | } 79 | #endregion Via EntraAuth 80 | 81 | #region Via Az.KeyVault 82 | if ((Get-Module Az.Accounts -ListAvailable) -and (Get-AzContext) -and (Get-Module Az.KeyVault -ListAvailable)) { 83 | try { $secret = Get-AzKeyVaultSecret -VaultName $VaultName -Name $SecretName } 84 | catch { Invoke-TerminatingException -Cmdlet $Cmdlet -ErrorRecord $_ -Message "Error accessing the secret '$Secretname' from Vault '$VaultName'. $_" } 85 | 86 | $type = 'Certificate' 87 | if (-not $secret.ContentType) { $type = 'ClientSecret' } 88 | 89 | $certificate = $null 90 | $clientSecret = $secret.SecretValue 91 | 92 | if ($type -eq 'Certificate') { 93 | $certString = [PSCredential]::New("irrelevant", $secret.SecretValue).GetNetworkCredential().Password 94 | $bytes = [convert]::FromBase64String($certString) 95 | $certificate = [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($bytes) 96 | $clientSecret = $null 97 | } 98 | 99 | [PSCustomObject]@{ 100 | Type = $type 101 | Certificate = $certificate 102 | ClientSecret = $clientSecret 103 | } 104 | 105 | return 106 | } 107 | #endregion Via Az.KeyVault 108 | 109 | Invoke-TerminatingException -Cmdlet $Cmdlet -Message "Not connected to azure yet! Either use 'Connect-EntraService -Service AzureKeyVault' or 'Connect-AzAccount' before trying to connect via KeyVault!" -Category ConnectionError 110 | } 111 | } -------------------------------------------------------------------------------- /EntraAuth/internal/functions/authentication/Read-AuthResponse.ps1: -------------------------------------------------------------------------------- 1 | function Read-AuthResponse { 2 | <# 3 | .SYNOPSIS 4 | Produces a standard output representation of the authentication response received. 5 | 6 | .DESCRIPTION 7 | Produces a standard output representation of the authentication response received. 8 | This streamlines the token processing and simplifies the connection code. 9 | 10 | .PARAMETER AuthResponse 11 | The authentication response received. 12 | 13 | .EXAMPLE 14 | PS C:\> Read-AuthResponse -AuthResponse $authResponse 15 | 16 | Reads the authentication details received. 17 | #> 18 | [CmdletBinding()] 19 | param ( 20 | $AuthResponse 21 | ) 22 | process { 23 | if ($AuthResponse.expires_in) { 24 | $after = (Get-Date).AddMinutes(-5) 25 | $until = (Get-Date).AddSeconds($AuthResponse.expires_in) 26 | } 27 | else { 28 | if ($AuthResponse.not_before) { $after = (Get-Date -Date '1970-01-01').AddSeconds($AuthResponse.not_before).ToLocalTime() } 29 | else { $after = Get-Date } 30 | $until = (Get-Date -Date '1970-01-01').AddSeconds($AuthResponse.expires_on).ToLocalTime() 31 | } 32 | $scopes = @() 33 | if ($AuthResponse.scope) { $scopes = $authResponse.scope -split " " } 34 | 35 | # If updating this layout, also update in Connect-ServiceAzure, which fakes this object 36 | [pscustomobject]@{ 37 | AccessToken = $AuthResponse.access_token 38 | ValidAfter = $after 39 | ValidUntil = $until 40 | Scopes = $scopes 41 | RefreshToken = $AuthResponse.refresh_token 42 | } 43 | } 44 | } -------------------------------------------------------------------------------- /EntraAuth/internal/functions/other/ConvertTo-Base64.ps1: -------------------------------------------------------------------------------- 1 | function ConvertTo-Base64 { 2 | <# 3 | .SYNOPSIS 4 | Converts the input-string to its base 64 encoded string form. 5 | 6 | .DESCRIPTION 7 | Converts the input-string to its base 64 encoded string form. 8 | 9 | .PARAMETER Text 10 | The text to convert. 11 | 12 | .PARAMETER Encoding 13 | The encoding of the input text. 14 | Used to correctly translate the input string into bytes before converting those to base 64. 15 | Defaults to UTF8 16 | 17 | .EXAMPLE 18 | PS C:\> Get-Content .\code.ps1 -Raw | ConvertTo-Base64 19 | 20 | Reads the input file and converts its content into base64. 21 | #> 22 | [OutputType([string])] 23 | [CmdletBinding()] 24 | param ( 25 | [Parameter(Mandatory = $true, ValueFromPipeline = $true)] 26 | [string[]] 27 | $Text, 28 | 29 | [System.Text.Encoding] 30 | $Encoding = [System.Text.Encoding]::UTF8 31 | ) 32 | 33 | process { 34 | foreach ($entry in $Text) { 35 | $bytes = $Encoding.GetBytes($entry) 36 | [Convert]::ToBase64String($bytes) 37 | } 38 | } 39 | } -------------------------------------------------------------------------------- /EntraAuth/internal/functions/other/ConvertTo-Hashtable.ps1: -------------------------------------------------------------------------------- 1 | function ConvertTo-Hashtable { 2 | <# 3 | .SYNOPSIS 4 | Converts input objects into hashtables. 5 | 6 | .DESCRIPTION 7 | Converts input objects into hashtables. 8 | Allows explicitly including some properties only and remapping key-names as required. 9 | 10 | .PARAMETER Include 11 | Only select the specified properties. 12 | 13 | .PARAMETER Mapping 14 | Remap hashtable/property keys. 15 | This allows you to rename parameters before passing them through to other commands. 16 | Example: 17 | @{ Select = '$select' } 18 | This will map the "Select"-property/key on the input object to be '$select' on the output item. 19 | 20 | .PARAMETER InputObject 21 | The object to convert. 22 | 23 | .EXAMPLE 24 | PS C:\> $__body = $PSBoundParameters | ConvertTo-Hashtable -Include Name, UserID -Mapping $__mapping 25 | 26 | Converts the object $PSBoundParameters into a hashtable, including the keys "Name" and "UserID" and remapping them as specified in $__mapping 27 | #> 28 | [OutputType([hashtable])] 29 | [CmdletBinding()] 30 | param ( 31 | [AllowEmptyCollection()] 32 | [string[]] 33 | $Include, 34 | 35 | [Hashtable] 36 | $Mapping = @{ }, 37 | 38 | [Parameter(ValueFromPipeline = $true)] 39 | $InputObject 40 | ) 41 | 42 | process { 43 | $result = @{ } 44 | if ($InputObject -is [System.Collections.IDictionary]) { 45 | foreach ($pair in $InputObject.GetEnumerator()) { 46 | if ($pair.Key -notin $Include) { continue } 47 | if ($Mapping[$pair.Key]) { $result[$Mapping[$pair.Key]] = $pair.Value } 48 | else { $result[$pair.Key] = $pair.Value } 49 | } 50 | } 51 | else { 52 | foreach ($property in $InputObject.PSObject.Properties) { 53 | if ($property.Name -notin $Include) { continue } 54 | if ($Mapping[$property.Name]) { $result[$Mapping[$property.Name]] = $property.Value } 55 | else { $result[$property.Name] = $property.Value } 56 | } 57 | } 58 | $result 59 | } 60 | } -------------------------------------------------------------------------------- /EntraAuth/internal/functions/other/ConvertTo-QueryString.ps1: -------------------------------------------------------------------------------- 1 | function ConvertTo-QueryString { 2 | <# 3 | .SYNOPSIS 4 | Convert conditions in a hashtable to a Query string to append to a webrequest. 5 | 6 | .DESCRIPTION 7 | Convert conditions in a hashtable to a Query string to append to a webrequest. 8 | 9 | .PARAMETER QueryHash 10 | Hashtable of query modifiers - usually filter conditions - to include in a web request. 11 | 12 | .PARAMETER DefaultQuery 13 | Default query parameters defined in the service configuration. 14 | Default query settings are overriden by explicit query parameters. 15 | 16 | .EXAMPLE 17 | PS C:\> ConvertTo-QueryString -QueryHash $Query 18 | 19 | Converts the conditions in the specified hashtable to a Query string to append to a webrequest. 20 | #> 21 | [OutputType([string])] 22 | [CmdletBinding()] 23 | param ( 24 | [Parameter(Mandatory = $true, ValueFromPipeline = $true)] 25 | [Hashtable] 26 | $QueryHash, 27 | 28 | [AllowNull()] 29 | [hashtable] 30 | $DefaultQuery 31 | ) 32 | 33 | process { 34 | if ($DefaultQuery) { $query = $DefaultQuery.Clone() } 35 | else { $query = @{} } 36 | 37 | foreach ($key in $QueryHash.Keys) { 38 | $query[$key] = $QueryHash[$key] 39 | } 40 | if ($query.Count -lt 1) { return '' } 41 | 42 | 43 | $elements = foreach ($pair in $query.GetEnumerator()) { 44 | '{0}={1}' -f $pair.Name, ($pair.Value -join ",") 45 | } 46 | '?{0}' -f ($elements -join '&') 47 | } 48 | } -------------------------------------------------------------------------------- /EntraAuth/internal/functions/other/ConvertTo-SignedString.ps1: -------------------------------------------------------------------------------- 1 | function ConvertTo-SignedString { 2 | <# 3 | .SYNOPSIS 4 | Signs input string with the offered certificate. 5 | 6 | .DESCRIPTION 7 | Signs input string with the offered certificate. 8 | 9 | .PARAMETER Text 10 | The text to sign. 11 | 12 | .PARAMETER Certificate 13 | The certificate to sign with. 14 | The Private Key must be available. 15 | 16 | .PARAMETER Padding 17 | What RSA Signature padding to use. 18 | Defaults to Pkcs1 19 | 20 | .PARAMETER Algorithm 21 | What algorithm to use for signing. 22 | Defaults to SHA256 23 | 24 | .PARAMETER Encoding 25 | The encoding to use for transforming the text to bytes before signing it. 26 | Defaults to UTF8 27 | 28 | .EXAMPLE 29 | PS C:\> ConvertTo-SignedString -Text $token 30 | 31 | Signs the specified token 32 | #> 33 | [OutputType([string])] 34 | [CmdletBinding()] 35 | param ( 36 | [Parameter(Mandatory = $true, ValueFromPipeline = $true)] 37 | [string[]] 38 | $Text, 39 | 40 | [System.Security.Cryptography.X509Certificates.X509Certificate2] 41 | $Certificate, 42 | 43 | [Security.Cryptography.RSASignaturePadding] 44 | $Padding = [Security.Cryptography.RSASignaturePadding]::Pkcs1, 45 | 46 | [Security.Cryptography.HashAlgorithmName] 47 | $Algorithm = [Security.Cryptography.HashAlgorithmName]::SHA256, 48 | 49 | [System.Text.Encoding] 50 | $Encoding = [System.Text.Encoding]::UTF8 51 | ) 52 | 53 | begin { 54 | $privateKey = [System.Security.Cryptography.X509Certificates.RSACertificateExtensions]::GetRSAPrivateKey($Certificate) 55 | } 56 | process { 57 | foreach ($entry in $Text) { 58 | $inBytes = $Encoding.GetBytes($entry) 59 | $outBytes = $privateKey.SignData($inBytes, $Algorithm, $Padding) 60 | [convert]::ToBase64String($outBytes) 61 | } 62 | } 63 | } -------------------------------------------------------------------------------- /EntraAuth/internal/functions/other/Invoke-TerminatingException.ps1: -------------------------------------------------------------------------------- 1 | function Invoke-TerminatingException { 2 | <# 3 | .SYNOPSIS 4 | Throw a terminating exception in the context of the caller. 5 | 6 | .DESCRIPTION 7 | Throw a terminating exception in the context of the caller. 8 | Masks the actual code location from the end user in how the message will be displayed. 9 | 10 | .PARAMETER Cmdlet 11 | The $PSCmdlet variable of the calling command. 12 | 13 | .PARAMETER Message 14 | The message to show the user. 15 | 16 | .PARAMETER Exception 17 | A nested exception to include in the exception object. 18 | 19 | .PARAMETER Category 20 | The category of the error. 21 | 22 | .PARAMETER ErrorRecord 23 | A full error record that was caught by the caller. 24 | Use this when you want to rethrow an existing error. 25 | 26 | .EXAMPLE 27 | PS C:\> Invoke-TerminatingException -Cmdlet $PSCmdlet -Message 'Unknown calling module' 28 | 29 | Terminates the calling command, citing an unknown caller. 30 | #> 31 | [CmdletBinding()] 32 | Param ( 33 | [Parameter(Mandatory = $true)] 34 | $Cmdlet, 35 | 36 | [string] 37 | $Message, 38 | 39 | [System.Exception] 40 | $Exception, 41 | 42 | [System.Management.Automation.ErrorCategory] 43 | $Category = [System.Management.Automation.ErrorCategory]::NotSpecified, 44 | 45 | [System.Management.Automation.ErrorRecord] 46 | $ErrorRecord 47 | ) 48 | 49 | process { 50 | if ($ErrorRecord -and -not $Message) { 51 | $Cmdlet.ThrowTerminatingError($ErrorRecord) 52 | } 53 | 54 | $exceptionType = switch ($Category) { 55 | default { [System.Exception] } 56 | 'InvalidArgument' { [System.ArgumentException] } 57 | 'InvalidData' { [System.IO.InvalidDataException] } 58 | 'AuthenticationError' { [System.Security.Authentication.AuthenticationException] } 59 | 'InvalidOperation' { [System.InvalidOperationException] } 60 | } 61 | 62 | 63 | if ($Exception) { $newException = $Exception.GetType()::new($Message, $Exception) } 64 | elseif ($ErrorRecord) { 65 | try { $newException = $ErrorRecord.Exception.GetType()::new($Message, $ErrorRecord.Exception) } 66 | catch { $newException = [System.Exception]::new($Message, $ErrorRecord.Exception) } 67 | } 68 | else { $newException = $exceptionType::new($Message) } 69 | $record = [System.Management.Automation.ErrorRecord]::new($newException, (Get-PSCallStack)[1].FunctionName, $Category, $Target) 70 | $Cmdlet.ThrowTerminatingError($record) 71 | } 72 | } -------------------------------------------------------------------------------- /EntraAuth/internal/functions/other/Read-TokenData.ps1: -------------------------------------------------------------------------------- 1 | function Read-TokenData { 2 | <# 3 | .SYNOPSIS 4 | Reads a JWT token and converts it into a custom object showing its properties. 5 | 6 | .DESCRIPTION 7 | Reads a JWT token and converts it into a custom object showing its properties. 8 | 9 | .PARAMETER Token 10 | The JWT Token to parse 11 | 12 | .EXAMPLE 13 | PS C:\> Read-TokenData -Token $authresponse.access_token 14 | 15 | Reads the settings on the returned access token. 16 | #> 17 | [CmdletBinding()] 18 | param ( 19 | [Parameter(Mandatory=$true)] 20 | [string] 21 | $Token 22 | ) 23 | process { 24 | $tokenPayload = $Token.Split(".")[1].Replace('-', '+').Replace('_', '/') 25 | # Pad with "=" until string length modulus 4 reaches 0 26 | while ($tokenPayload.Length % 4) { $tokenPayload += "=" } 27 | $bytes = [System.Convert]::FromBase64String($tokenPayload) 28 | [System.Text.Encoding]::ASCII.GetString($bytes) | ConvertFrom-Json 29 | } 30 | } -------------------------------------------------------------------------------- /EntraAuth/internal/functions/other/Resolve-Certificate.ps1: -------------------------------------------------------------------------------- 1 | function Resolve-Certificate { 2 | <# 3 | .SYNOPSIS 4 | Helper function to resolve certificate input. 5 | 6 | .DESCRIPTION 7 | Helper function to resolve certificate input. 8 | This function expects the full $PSBoundParameters from the calling command and will (in this order) look for these parameter names: 9 | 10 | + Certificate: A full X509Certificate2 object with private key 11 | + CertificateThumbprint: The thumbprint of a certificate to use. Will look first in the user store, then the machine store for it. 12 | + CertificateName: The subject of the certificate to look for. Will look first in the user store, then the machine store for it. Will select the certificate with the longest expiration period. 13 | + CertificatePath: Path to a PFX file to load. Also expects a CertificatePassword parameter to unlock the file. 14 | 15 | .PARAMETER BoundParameters 16 | The $PSBoundParameter variable of the caller to simplify passthrough. 17 | See Description for more details on what the command expects, 18 | 19 | .EXAMPLE 20 | PS C:\> $certificateObject = Resolve-Certificate -BoundParameters $PSBoundParameters 21 | 22 | Resolves the certificate based on the parameters provided to the calling command. 23 | #> 24 | [OutputType([System.Security.Cryptography.X509Certificates.X509Certificate2])] 25 | [CmdletBinding()] 26 | param ( 27 | $BoundParameters 28 | ) 29 | 30 | if ($BoundParameters.Certificate) { return $BoundParameters.Certificate } 31 | if ($BoundParameters.CertificateThumbprint) { 32 | if (Test-Path -Path "cert:\CurrentUser\My\$($BoundParameters.CertificateThumbprint)") { 33 | return Get-Item "cert:\CurrentUser\My\$($BoundParameters.CertificateThumbprint)" 34 | } 35 | if (Test-Path -Path "cert:\LocalMachine\My\$($BoundParameters.CertificateThumbprint)") { 36 | return Get-Item "cert:\LocalMachine\My\$($BoundParameters.CertificateThumbprint)" 37 | } 38 | Invoke-TerminatingException -Cmdlet $PSCmdlet -Message "Unable to find certificate with thumbprint '$($BoundParameters.CertificateThumbprint)'" 39 | } 40 | if ($BoundParameters.CertificateName) { 41 | if ($certificate = @(Get-ChildItem 'Cert:\CurrentUser\My\').Where{ $_.Subject -eq $BoundParameters.CertificateName -and $_.HasPrivateKey }) { 42 | return $certificate | Sort-Object NotAfter -Descending | Select-Object -First 1 43 | } 44 | if ($certificate = @(Get-ChildItem 'Cert:\LocalMachine\My\').Where{ $_.Subject -eq $BoundParameters.CertificateName -and $_.HasPrivateKey }) { 45 | return $certificate | Sort-Object NotAfter -Descending | Select-Object -First 1 46 | } 47 | Invoke-TerminatingException -Cmdlet $PSCmdlet -Message "Unable to find certificate with subject '$($BoundParameters.CertificateName)'" 48 | } 49 | if ($BoundParameters.CertificatePath) { 50 | try { [System.Security.Cryptography.X509Certificates.X509Certificate2]::new($BoundParameters.CertificatePath, $BoundParameters.CertificatePassword) } 51 | catch { 52 | Invoke-TerminatingException -Cmdlet $PSCmdlet -Message "Unable to load certificate from file '$($BoundParameters.CertificatePath)': $_" -ErrorRecord $_ 53 | } 54 | } 55 | } -------------------------------------------------------------------------------- /EntraAuth/internal/functions/other/Resolve-RequestUri.ps1: -------------------------------------------------------------------------------- 1 | function Resolve-RequestUri { 2 | <# 3 | .SYNOPSIS 4 | Resolves the actual Uri used for a request in Invoke-EntraRequest. 5 | 6 | .DESCRIPTION 7 | Resolves the actual Uri used for a request in Invoke-EntraRequest. 8 | If the path provided is a full url, it will be returned as is. 9 | Otherwise, any present parameters will be resolved in the base service url before merging it with the specified path. 10 | 11 | .PARAMETER TokenObject 12 | The object representing the token used for the request. 13 | 14 | .PARAMETER ServiceObject 15 | The service object (if any) used with the request. 16 | The parameters to be inserted into the query will be read from here. 17 | 18 | .PARAMETER BoundParameters 19 | The parameters provided to Invoke-EntraRequest. 20 | 21 | .EXAMPLE 22 | PS C:\> Resolve-RequestUri -TokenObject $tokenObject -ServiceObject $script:_EntraEndpoints.$($tokenObject.Service) -BoundParameters $PSBoundParameters 23 | 24 | Resolves the uri for the needed request based on token, service and parameters provided 25 | #> 26 | [OutputType([string])] 27 | [CmdletBinding()] 28 | param ( 29 | [Parameter(Mandatory = $true)] 30 | $TokenObject, 31 | 32 | [Parameter(Mandatory = $true)] 33 | [AllowNull()] 34 | $ServiceObject, 35 | 36 | [Parameter(Mandatory = $true)] 37 | $BoundParameters 38 | ) 39 | process { 40 | if ($BoundParameters.Path -match '^https{0,1}://') { 41 | return $BoundParameters.Path 42 | } 43 | 44 | $serviceUrlBase = $TokenObject.ServiceUrl.Trim() 45 | foreach ($key in $ServiceObject.Parameters.Keys) { 46 | $serviceUrlBase = $serviceUrlBase -replace "%$key%", $BoundParameters.$key 47 | } 48 | 49 | "$($serviceUrlBase.TrimEnd('/'))/$($Path.TrimStart('/'))" 50 | } 51 | } -------------------------------------------------------------------------------- /EntraAuth/internal/functions/other/Resolve-ScopeName.ps1: -------------------------------------------------------------------------------- 1 | function Resolve-ScopeName { 2 | <# 3 | .SYNOPSIS 4 | Normalizes scope names. 5 | 6 | .DESCRIPTION 7 | Normalizes scope names. 8 | To help manage correct scopes naming with services that don't map directly to their urls. 9 | 10 | .PARAMETER Scopes 11 | The scopes to normalize. 12 | 13 | .PARAMETER Resource 14 | The Resource the scopes are meant for. 15 | 16 | .EXAMPLE 17 | PS C:\> $scopes | Resolve-ScopeName -Resource $Resource 18 | 19 | Resolves all them scopes 20 | #> 21 | [CmdletBinding()] 22 | param ( 23 | [Parameter(ValueFromPipeline = $true)] 24 | [string[]] 25 | $Scopes, 26 | 27 | [Parameter(Mandatory = $true)] 28 | [string] 29 | $Resource 30 | ) 31 | process { 32 | foreach ($scope in $Scopes) { 33 | foreach ($scope in $Scopes) { 34 | if ($scope -like 'https://*/*') { $scope } 35 | elseif ($scope -like 'api:/') { $scope } 36 | else { "{0}/{1}" -f $Resource, $scope } 37 | } 38 | } 39 | } 40 | } -------------------------------------------------------------------------------- /EntraAuth/internal/functions/readme.md: -------------------------------------------------------------------------------- 1 | # Internal > Functions 2 | 3 | Folder for all the functions you want the user to not see. 4 | -------------------------------------------------------------------------------- /EntraAuth/internal/functions/ux/Assert-ServiceName.ps1: -------------------------------------------------------------------------------- 1 | function Assert-ServiceName { 2 | <# 3 | .SYNOPSIS 4 | Asserts a service name actually exists. 5 | 6 | .DESCRIPTION 7 | Asserts a service name actually exists. 8 | Used in validation scripts to ensure proper service names were provided. 9 | 10 | .PARAMETER Name 11 | The name of the service to verify. 12 | 13 | .PARAMETER IncludeTokens 14 | Also include registered token's services in the assertion. 15 | By default, the assertion will only verify the existence of registered services. 16 | 17 | .EXAMPLE 18 | PS C:\> Assert-ServiceName -Name $_ 19 | 20 | Returns $true if the service exists and throws a terminating exception if not so. 21 | #> 22 | [OutputType([bool])] 23 | [CmdletBinding()] 24 | param ( 25 | [Parameter(Mandatory = $true)] 26 | [AllowEmptyString()] 27 | [AllowNull()] 28 | [string] 29 | $Name, 30 | 31 | [switch] 32 | $IncludeTokens 33 | ) 34 | process { 35 | if ($script:_EntraEndpoints.Keys -contains $Name) { return $true } 36 | if ($IncludeTokens -and $script:_EntraTokens.Keys -contains $Name) { return $true } 37 | 38 | $serviceNames = $script:_EntraEndpoints.Keys -join ', ' 39 | Write-Warning "Invalid service name: '$Name'. Legal service names: $serviceNames" 40 | Invoke-TerminatingException -Cmdlet $PSCmdlet -Message "Invalid service name: '$Name'. Legal service names: $serviceNames" 41 | } 42 | } -------------------------------------------------------------------------------- /EntraAuth/internal/functions/ux/Get-ServiceCompletion.ps1: -------------------------------------------------------------------------------- 1 | function global:Get-ServiceCompletion { 2 | <# 3 | .SYNOPSIS 4 | Returns the values to complete for.service names. 5 | 6 | .DESCRIPTION 7 | Returns the values to complete for.service names. 8 | Use this command in argument completers. 9 | 10 | .PARAMETER ArgumentList 11 | The arguments an argumentcompleter receives. 12 | The third item will be the word to complete. 13 | 14 | .EXAMPLE 15 | PS C:\> Get-ServiceCompletion -ArgumentList $args 16 | 17 | Returns the values to complete for.service names. 18 | #> 19 | [OutputType([System.Management.Automation.CompletionResult])] 20 | [CmdletBinding()] 21 | param ( 22 | $ArgumentList 23 | ) 24 | process { 25 | $wordToComplete = $ArgumentList[2].Trim("'`"") 26 | foreach ($service in Get-EntraService) { 27 | if ($service.Name -notlike "$($wordToComplete)*") { continue } 28 | 29 | $text = if ($service.Name -notmatch '\s') { $service.Name } else { "'$($service.Name)'" } 30 | [System.Management.Automation.CompletionResult]::new( 31 | $text, 32 | $text, 33 | 'Text', 34 | $service.ServiceUrl 35 | ) 36 | } 37 | } 38 | } 39 | $ExecutionContext.InvokeCommand.GetCommand("Get-ServiceCompletion","Function").Visibility = 'Private' -------------------------------------------------------------------------------- /EntraAuth/internal/scripts/01-variables.ps1: -------------------------------------------------------------------------------- 1 | # Available Tokens 2 | $script:_EntraTokens = @{} 3 | 4 | # Endpoint Configuration for Requests 5 | $script:_EntraEndpoints = @{} 6 | 7 | # The default service to connect to 8 | $script:_DefaultService = 'Graph' -------------------------------------------------------------------------------- /EntraAuth/internal/scripts/02-Services.ps1: -------------------------------------------------------------------------------- 1 | # Registers the default service configurations 2 | $endpointCfg = @{ 3 | Name = 'Endpoint' 4 | ServiceUrl = 'https://api.securitycenter.microsoft.com/api' 5 | Resource = 'https://api.securitycenter.microsoft.com' 6 | DefaultScopes = @() 7 | Header = @{ 'Content-Type' = 'application/json' } 8 | HelpUrl = 'https://learn.microsoft.com/en-us/microsoft-365/security/defender-endpoint/api/apis-intro?view=o365-worldwide' 9 | } 10 | Register-EntraService @endpointCfg 11 | 12 | $securityCfg = @{ 13 | Name = 'Security' 14 | ServiceUrl = 'https://api.security.microsoft.com/api' 15 | Resource = 'https://security.microsoft.com/mtp/' 16 | DefaultScopes = @('AdvancedHunting.Read') 17 | Header = @{ 'Content-Type' = 'application/json' } 18 | HelpUrl = 'https://learn.microsoft.com/en-us/microsoft-365/security/defender/api-create-app-web?view=o365-worldwide' 19 | } 20 | Register-EntraService @securityCfg 21 | 22 | $graphCfg = @{ 23 | Name = 'Graph' 24 | ServiceUrl = 'https://graph.microsoft.com/v1.0' 25 | Resource = 'https://graph.microsoft.com' 26 | DefaultScopes = @() 27 | HelpUrl = 'https://developer.microsoft.com/en-us/graph/quick-start' 28 | } 29 | Register-EntraService @graphCfg 30 | 31 | $graphBetaCfg = @{ 32 | Name = 'GraphBeta' 33 | ServiceUrl = 'https://graph.microsoft.com/beta' 34 | Resource = 'https://graph.microsoft.com' 35 | DefaultScopes = @() 36 | HelpUrl = 'https://developer.microsoft.com/en-us/graph/quick-start' 37 | } 38 | Register-EntraService @graphBetaCfg 39 | 40 | $azureCfg = @{ 41 | Name = 'Azure' 42 | ServiceUrl = 'https://management.azure.com' 43 | Resource = 'https://management.core.windows.net/' 44 | DefaultScopes = @() 45 | HelpUrl = 'https://learn.microsoft.com/en-us/rest/api/azure/?view=rest-resources-2022-12-01' 46 | } 47 | Register-EntraService @azureCfg 48 | 49 | $azureKeyVaultCfg = @{ 50 | Name = 'AzureKeyVault' 51 | ServiceUrl = 'https://%VAULTNAME%.vault.azure.net' 52 | Resource = 'https://vault.azure.net' 53 | DefaultScopes = @() 54 | HelpUrl = 'https://learn.microsoft.com/en-us/rest/api/keyvault/?view=rest-keyvault-secrets-7.4' 55 | Parameters = @{ 56 | VaultName = 'Name of the Key Vault to execute against' 57 | } 58 | Query = @{ 59 | 'api-version' = '7.4' 60 | } 61 | } 62 | Register-EntraService @azureKeyVaultCfg -------------------------------------------------------------------------------- /EntraAuth/internal/scripts/readme.md: -------------------------------------------------------------------------------- 1 | # Internal > Scripts 2 | 3 | Put in all the scripts that should be run once during import 4 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Friedrich Weinmann 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /build/vsts-build.ps1: -------------------------------------------------------------------------------- 1 | <# 2 | This script publishes the module to the gallery. 3 | It expects as input an ApiKey authorized to publish the module. 4 | 5 | Insert any build steps you may need to take before publishing it here. 6 | #> 7 | param ( 8 | $ApiKey, 9 | 10 | $WorkingDirectory, 11 | 12 | $Repository = 'PSGallery', 13 | 14 | [switch] 15 | $LocalRepo, 16 | 17 | [switch] 18 | $SkipPublish, 19 | 20 | [switch] 21 | $AutoVersion 22 | ) 23 | 24 | #region Handle Working Directory Defaults 25 | if (-not $WorkingDirectory) 26 | { 27 | if ($env:RELEASE_PRIMARYARTIFACTSOURCEALIAS) 28 | { 29 | $WorkingDirectory = Join-Path -Path $env:SYSTEM_DEFAULTWORKINGDIRECTORY -ChildPath $env:RELEASE_PRIMARYARTIFACTSOURCEALIAS 30 | } 31 | else { $WorkingDirectory = $env:SYSTEM_DEFAULTWORKINGDIRECTORY } 32 | } 33 | if (-not $WorkingDirectory) { $WorkingDirectory = Split-Path $PSScriptRoot } 34 | #endregion Handle Working Directory Defaults 35 | 36 | # Prepare publish folder 37 | Write-Host "Creating and populating publishing directory" 38 | $publishDir = New-Item -Path $WorkingDirectory -Name publish -ItemType Directory -Force 39 | Copy-Item -Path "$($WorkingDirectory)\EntraAuth" -Destination $publishDir.FullName -Recurse -Force 40 | 41 | #region Gather text data to compile 42 | $text = @('$script:ModuleRoot = $PSScriptRoot') 43 | 44 | # Gather Classes 45 | Get-ChildItem -Path "$($publishDir.FullName)\EntraAuth\internal\classes\" -Recurse -File -Filter "*.ps1" | ForEach-Object { 46 | $text += [System.IO.File]::ReadAllText($_.FullName) 47 | } 48 | 49 | # Gather commands 50 | Get-ChildItem -Path "$($publishDir.FullName)\EntraAuth\internal\functions\" -Recurse -File -Filter "*.ps1" | ForEach-Object { 51 | $text += [System.IO.File]::ReadAllText($_.FullName) 52 | } 53 | Get-ChildItem -Path "$($publishDir.FullName)\EntraAuth\functions\" -Recurse -File -Filter "*.ps1" | ForEach-Object { 54 | $text += [System.IO.File]::ReadAllText($_.FullName) 55 | } 56 | 57 | # Gather scripts 58 | Get-ChildItem -Path "$($publishDir.FullName)\EntraAuth\internal\scripts\" -Recurse -File -Filter "*.ps1" | ForEach-Object { 59 | $text += [System.IO.File]::ReadAllText($_.FullName) 60 | } 61 | 62 | # Postprocess Text 63 | $text = $text -replace '#%UNCOMMENT%' 64 | 65 | #region Update the psm1 file & Cleanup 66 | [System.IO.File]::WriteAllText("$($publishDir.FullName)\EntraAuth\EntraAuth.psm1", ($text -join "`n`n"), [System.Text.Encoding]::UTF8) 67 | Remove-Item -Path "$($publishDir.FullName)\EntraAuth\internal" -Recurse -Force 68 | Remove-Item -Path "$($publishDir.FullName)\EntraAuth\functions" -Recurse -Force 69 | #endregion Update the psm1 file & Cleanup 70 | 71 | #region Updating the Module Version 72 | if ($AutoVersion) 73 | { 74 | Write-Host "Updating module version numbers." 75 | try { [version]$remoteVersion = (Find-Module 'EntraAuth' -Repository $Repository -ErrorAction Stop).Version } 76 | catch 77 | { 78 | throw "Failed to access $($Repository) : $_" 79 | } 80 | if (-not $remoteVersion) 81 | { 82 | throw "Couldn't find EntraAuth on repository $($Repository) : $_" 83 | } 84 | $newBuildNumber = $remoteVersion.Build + 1 85 | [version]$localVersion = (Import-PowerShellDataFile -Path "$($publishDir.FullName)\EntraAuth\EntraAuth.psd1").ModuleVersion 86 | Update-ModuleManifest -Path "$($publishDir.FullName)\EntraAuth\EntraAuth.psd1" -ModuleVersion "$($localVersion.Major).$($localVersion.Minor).$($newBuildNumber)" 87 | } 88 | #endregion Updating the Module Version 89 | 90 | #region Publish 91 | if ($SkipPublish) { return } 92 | if ($LocalRepo) 93 | { 94 | # Dependencies must go first 95 | Write-Host "Creating Nuget Package for module: PSFramework" 96 | New-PSMDModuleNugetPackage -ModulePath (Get-Module -Name PSFramework).ModuleBase -PackagePath . 97 | Write-Host "Creating Nuget Package for module: EntraAuth" 98 | New-PSMDModuleNugetPackage -ModulePath "$($publishDir.FullName)\EntraAuth" -PackagePath . 99 | } 100 | else 101 | { 102 | # Publish to Gallery 103 | Write-Host "Publishing the EntraAuth module to $($Repository)" 104 | Publish-Module -Path "$($publishDir.FullName)\EntraAuth" -NuGetApiKey $ApiKey -Force -Repository $Repository 105 | } 106 | #endregion Publish -------------------------------------------------------------------------------- /build/vsts-prerequisites.ps1: -------------------------------------------------------------------------------- 1 | param ( 2 | [string] 3 | $Repository = 'PSGallery' 4 | ) 5 | 6 | $modules = @("Pester", "PSScriptAnalyzer") 7 | 8 | # Automatically add missing dependencies 9 | $data = Import-PowerShellDataFile -Path "$PSScriptRoot\..\EntraAuth\EntraAuth.psd1" 10 | foreach ($dependency in $data.RequiredModules) { 11 | if ($dependency -is [string]) { 12 | if ($modules -contains $dependency) { continue } 13 | $modules += $dependency 14 | } 15 | else { 16 | if ($modules -contains $dependency.ModuleName) { continue } 17 | $modules += $dependency.ModuleName 18 | } 19 | } 20 | 21 | foreach ($module in $modules) { 22 | Write-Host "Installing $module" -ForegroundColor Cyan 23 | Install-Module $module -Force -SkipPublisherCheck -Repository $Repository 24 | Import-Module $module -Force -PassThru 25 | } -------------------------------------------------------------------------------- /build/vsts-validate.ps1: -------------------------------------------------------------------------------- 1 | # Run internal pester tests 2 | & "$PSScriptRoot\..\tests\pester.ps1" -------------------------------------------------------------------------------- /docs/api-permissions.md: -------------------------------------------------------------------------------- 1 | # API Permissions and you 2 | 3 | > [Back to Overview](overview.md) 4 | 5 | |[Previous: Application vs. Delegate Authentication](application-vs-delegate.md)|[Next: Managing an Application & Troubleshooting logins](managing-applications.md)| 6 | 7 | So, understanding the difference between Application and Delegate authentication, you now want to get yourself some juicy permissions, right? 8 | 9 | For that we first need to navigate to the "API permissions" section in our Application's page: 10 | 11 | ![A table of permissions, currently only showing the default "User.Read" delegate right and the option to add more rights](pictures/C-01-ApiPermission-Portal.png) 12 | 13 | As we can see, this new application only has a single permission granted by default - "User.Read", the Delegate right for a user to retrieve information about themselves. 14 | 15 | > Note on terminology: The individual API permissions are frequently called "Scopes". 16 | 17 | To request more permissions, we need to first select the "Add a permission" button above the table. 18 | This opens a new panel, where we first need to select, from _which_ service we want permissions: 19 | 20 | ![A grid of panels, each representing a service, the "Microsoft Graph" service prominently on the top](pictures/C-02-RequestPermissions.png) 21 | 22 | If your service is listed here: 23 | Great. 24 | We will cover how to find unlisted services later. 25 | 26 | Let us assume that for the purpose of our project we need the Microsoft Graph service, as we later want to modify the groups we have access to: 27 | 28 | ![A new section for Microsoft Graph, offering us the choice between two panels - Delegated permissions or Application permissions](pictures/C-03-ApplicationDelegate.png) 29 | 30 | Again we are faced with the choice between Delegated and Application permissions. 31 | As we are going for an interactive tool, we pick the Delegated permissions: 32 | 33 | ![The previous image expanded downwards with a search bar and permissions offered](pictures/C-04-ScopesFilter.png) 34 | 35 | We can now search all the permissions the service - in this case Microsoft Graph - offers in Delegated mode. 36 | Using the search panel we can make it easier to find what is needed, then select the permissions' checkboxes and select "Add permissions": 37 | 38 | ![The previous image continued, with the checkbox beside "Group.ReadWrite.All" checked and the mouse over a blue "Add permission" button, ready to click](pictures/C-05-ScopesAssign.png) 39 | 40 | With that selected, we return to the main table of "API permissions": 41 | 42 | ![A table of permissions, now listing both "User.Read" and "Group.ReadWrite.All", a warning showing the new permission to require Admin Consent](pictures/C-06-ConsentPending.png) 43 | 44 | As we can see, the new permission is listed, but there is a new warning: 45 | ReadWrite for all groups appear a bit permissive and we now need the consent from a Global Administrator for the permission to apply. 46 | 47 | Fortunately, in my test tenant that is not a problem - the user already is Global Administrator. 48 | If you are not GA - most organizations try to minimize the number of accounts with that right - you will now have to ask one of them to perform the next steps. 49 | 50 | Either way, with the "Grant admin consent for %tenantname%" a GA can now grant the consent and make the permissions apply: 51 | 52 | ![A simple "Grant admin consent confirmation" box with Yes/No options](pictures/C-07-ConsentGranting.png) 53 | 54 | And with that, the consent has now been granted: 55 | 56 | ![A table of permissions, now both permissions flagged green as Consent having been granted](pictures/C-08-ConsentGranted.png) 57 | 58 | ## Service Not Found 59 | 60 | So far, so good. 61 | Some of the well-known APIs / Services are easy to find when trying to add API permissions. 62 | But ... what if our service is not? 63 | Whether it is some Defender API or maybe our own function app, not all services will be found on the main grid panel of services. 64 | 65 | Still, it can be found: 66 | 67 | ![The top of the "Request API permissions" panel, showing three tabs with "Microsoft APIs" selected, "APIs my organization uses" and "My APIs" not selected](pictures/C-09-UnknownService.png) 68 | 69 | The tab "APIs my organization uses" hides all the remaining services in a tenant: 70 | 71 | ![The second tab selected, we now have a table of services and a search box at the top](pictures/C-10-SearchingService.png) 72 | 73 | Using the search box, we can now search for any service in our tenant. 74 | Note, the search is not always convenient and a name that should match does not return anything. 75 | 76 | + If you have the Application ID of the service, searching by that will always be precise. 77 | + If you have some online guide with screenshots, the header above the individual permissions (in our last screenshot: "Microsoft Graph (2)") shows the name to search for. 78 | 79 | |[Previous: Application vs. Delegate Authentication](application-vs-delegate.md)|[Next: Managing an Application & Troubleshooting logins](managing-applications.md)| 80 | -------------------------------------------------------------------------------- /docs/application-vs-delegate.md: -------------------------------------------------------------------------------- 1 | # Application vs. Delegate Authentication 2 | 3 | > [Back to Overview](overview.md) 4 | 5 | |[Previous: Creating an Application](creating-applications.md)|[Next: API Permissions and you](api-permissions.md)| 6 | 7 | ## The Different Kinds of Authenticating 8 | 9 | So, you have created an application and now want to connect, right? 10 | 11 | Well, you are almost there, but first you need to declare what rights you want to use after connecting. 12 | Which requires us to talk about the two different categories of authentication: 13 | 14 | + Application 15 | + Delegate 16 | 17 | When we get to assigning permissions, this difference is critical, as the two categories have completely different sets of rights. 18 | 19 | So ... what's the difference? 20 | 21 | > Application 22 | 23 | Application authentication flows assume, that there is no human being involved in the process. 24 | This is the classic "Service Account" kind of authentication you might already know from task schedulers or cron jobs doing their thing automatically as a specified account or as the "System". 25 | 26 | Whether that reference means something to you or not, this is basic unattended authentication, so no MFA prompt, no human interaction possible. 27 | 28 | This means we usually connect using a certificate (preferred) or a Client Secret. 29 | 30 | Using this mechanism, our code will act under the application itself - it basically becomes our service account - and all permissions assigned are _right grants_. 31 | In other words, if I assign the api permission "Group.ReadWrite.All" this means we now have the permission to edit _every single group in the tenant!_ 32 | 33 | This usually means an Admin must provide consent to those rights (more on that in the next chapter). 34 | 35 | > Delegate 36 | 37 | In Delegate mode, the application acts in the name of the user connecting. 38 | So if we use this to connect, we will log in as ourselves, the human user and the code will act in our own name - the application is now merely the configuration describing _how_ we connect. 39 | 40 | This logon expects there to be a human in front of the computer, ready to interact - for example servicing MFA prompts. 41 | Most flows require a browser, whether on the same machine that you are connecting from or another. 42 | 43 | In opposite to Application mode, api permissions in this mode are not _right grants_. 44 | They are a mask, a subset, of what your user account is already allowed to do. 45 | The api permission "Group.ReadWrite.All" allows you to edit all the groups you already have permission to modify. 46 | The api permission "Send.Mail" allows you to send emails as yourself only. 47 | 48 | Consequently, not all those api permissions require admin consent - for some the user may consent for themselves. 49 | 50 | ## The right tool for the job 51 | 52 | So, what authentication should I use when? 53 | The first decision is Application vs. Delegate - is this going to run unattended? 54 | If so, then Application it is. 55 | Do we have a human being in front of the console, ready to interact? 56 | Then consider using delegate mode. 57 | 58 | In some cases, even though a human is available, due to the rights situation you still need to use Application mode, but try to minimize this where possible - in most cases, Application mode is more rights than actually needed. 59 | 60 | There are different options within a given authentication mode however: 61 | 62 | > Application 63 | 64 | The two options are ... 65 | 66 | + Certificate 67 | + Client Secret (API Key) 68 | 69 | Certificate is the technically better option and should be the default choice, the Client Secret only in rare cases where the certificate logistics are too challenging. 70 | 71 | With Certificates, we sign the authentication request, while with Client Secret we send our secret over the network. 72 | Hence Certificate is the more secure choice. 73 | The Certificate can be a self-signed certificate. 74 | 75 | > Delegate 76 | 77 | The two main options are ... 78 | 79 | + Browser Logon (Authorization Code Flow) 80 | + Device Code Logon 81 | 82 | In both cases you log in via a browser window. 83 | The main difference between the two is that Browser Logon is more comfortable to use and must happen on the same computer where our code runs. 84 | Device Code Logon means your code sends the request, but the actual logon in the Browser can happen on any computer. 85 | 86 | If the computer executing the code has a user interface and a browser, then the former option (Browser Logon) is always the preferred option. 87 | Device Code - in which you execute the authentication independent of the connecting computer - requires you to loosen security requirements and is more vulnerable to token theft. 88 | 89 | On the other hand, if the computer executing the code has no user interface - just a console - Device Code is still the best option available. 90 | 91 | |[Previous: Creating an Application](creating-applications.md)|[Next: API Permissions and you](api-permissions.md)| 92 | -------------------------------------------------------------------------------- /docs/authenticate-browser.md: -------------------------------------------------------------------------------- 1 | # Delegated: Using the Browser directly 2 | 3 | > [Back to Overview](overview.md) 4 | 5 | ## Configure 6 | 7 | Setting up the default login via Browser is fortunately not too complex: 8 | In our App Registration configuration page, we select the "Authentication" tab: 9 | 10 | ![The App Registration Configuration page for Authentication: Shows the default settings, highlighting the "Add a platform" button](pictures/01-01-Authentication.png) 11 | 12 | Select "Add a platform" from this page. 13 | 14 | ![A grid of options, what kind of platform to configure. The panel for "Mobile and desktop applications" has been highlighted](pictures/01-02-Platform.png) 15 | 16 | Choose "Mobile and desktop applications". 17 | In the follow up menu we can now configure what adjustments we need to add: 18 | 19 | ![A panel allowing us to configure "Desktop + devices" redirect uris. There are three links already configured and an input textbox for your own entry](pictures/01-03-RedirectUri.png) 20 | 21 | All we need to do now, is to add "http://localhost" and select "configure": 22 | 23 | ![The same panel, with the textbox now filled out with "http://localhost". The "Configure" button is no longer greyed out.](pictures/01-04-localhost.png) 24 | 25 | And with that we are done! 26 | 27 | ![Again the authentication main screen, the "Platform configurations" section now has a "Mobile and desktop applications" panel with the url we just configured](pictures/01-05-Done.png) 28 | 29 | ## Authentication & Executing Queries 30 | 31 | Using the EntraAuth PowerShell module, we can now connect using our Application, authenticating in our Browser window: 32 | 33 | ```powershell 34 | $clientID = '63a71861-498b-46ae-0000-6b5c142010e1' 35 | $tenantID = 'a948c2b3-8eb2-498a-0000-c32aeeaa0f90' 36 | 37 | Connect-EntraService -ClientID $clientID -TenantID $tenantID 38 | ``` 39 | 40 | Once connected, we are now ready to use the connection to query all groups in our tenant: 41 | 42 | ```powershell 43 | Invoke-EntraRequest -Path groups 44 | ``` 45 | 46 | > This example assumes, that we followed the guide on setting up App Registrations and granted the `Group.ReadWrite.All` API permission for Microsoft Graph 47 | -------------------------------------------------------------------------------- /docs/authenticate-certificate.md: -------------------------------------------------------------------------------- 1 | # Application: Certificate 2 | 3 | > [Back to Overview](overview.md) 4 | 5 | ## Configure 6 | 7 | The Certificate-based authentication flow requires ... well, a certificate! 8 | The important part here: 9 | Whether it is a certificate from a trusted root CA or not, matters not at all. 10 | Authentication will work either way, self-signed or formal certificate, so pick your poison. 11 | 12 | That said, for this demo we will use a self-signed certificate, as that is quite easy to do and globally applicable. 13 | To generate a certificate, all we need is a PowerShell console: 14 | 15 | ```powershell 16 | $cert = New-SelfSignedCertificate -Subject 'CN=EntraAuth demo certificate' -CertStoreLocation 'Cert:\CurrentUser\My' 17 | ``` 18 | 19 | To store the certificate in the computer certificate store, rather than the user certificate store, replace `'Cert:\CurrentUser\My'` with `'Cert:\LocalMachine\My'` and execute this in an elevated ("Run as administrator") console. 20 | 21 | > This sample assumes you are using a Windows operating system. 22 | > How to obtain, store and retrieve a certificate in another OS is beyond the scope of this guide, but otherwise works just the same, once we have the certificate object. 23 | 24 | Now, in order for our Application to use this certificate for authentication, we must somehow make this certificate available to it. 25 | The easiest way to do so, now that we have the `$cert` variable is to export the public certificate into a file and upload it via the portal: 26 | 27 | ```powershell 28 | $bytes = $cert.GetRawCertData() 29 | [System.IO.File]::WriteAllBytes("C:\temp\cert.cer", $bytes) 30 | ``` 31 | 32 | Now to the portal: 33 | 34 | ![In the "Certificates & Secrets" section, we show three tabs ("Certificates", "Client secrets" & "Federated credentials"), with "Certificates" already selected. Beneath that is a highlighted "Upload certificate" button and an empty table](pictures/03-01-Certificates.png) 35 | 36 | Go the to "Certificates & Secrets" section of the App Registration menu, select the "Certificates" tab and then the button "Upload Certificate". 37 | This leads us to the menu to upload our new certificate: 38 | 39 | ![A small web formular titled "Upload Certificate". It has two panels - one for the file and one for a description. There's a "Select file" button beside the textbox for the file](pictures/03-02-Selection.png) 40 | 41 | Select the blue button beside the "Select File" panel and pick the certificate file we just wrote to file in the file picker that opens. 42 | 43 | ![The same formular, but the "cert.cer" file has been inserted into the file panel and the "Add" button at the bottom is no longer greyed out](pictures/03-03-Completed.png) 44 | 45 | And that's it, we are now ready to authenticate: 46 | 47 | ![The same "Certificates & Secrets" section as before, but the previously empty table now shows our certificate](pictures/03-04-Finished.png) 48 | 49 | ## Authentication & Executing Queries 50 | 51 | > This example assumes that you have granted the "Group.Read.All" Graph API permission to the application. 52 | > The example on configuring API Permissions in this guide uses Delegated permissions instead, _which do not apply to Application Authentication flows!_ 53 | 54 | Using the EntraAuth PowerShell module, we can now connect using our Application, authenticating with our certificate. 55 | 56 | ```powershell 57 | $clientID = '63a71861-498b-46ae-0000-6b5c142010e1' 58 | $tenantID = 'a948c2b3-8eb2-498a-0000-c32aeeaa0f90' 59 | 60 | # Connect using the certificate object we still have in our variable 61 | Connect-EntraService -ClientID $clientID -TenantID $tenantID -Certificate $cert 62 | 63 | # Connect via Certificate thumbprint, cert selected from cert store 64 | Connect-EntraService -ClientID $clientID -TenantID $tenantID -CertificateThumbprint 690667761F6E285B2A6AEFF098B886263433FB54 65 | 66 | # Connect via Certificate Subject, cert selected from cert store 67 | Connect-EntraService -ClientID $clientID -TenantID $tenantID -CertificateName 'CN=EntraAuth demo certificate' 68 | ``` 69 | 70 | Once connected, we are now ready to use the connection to query all groups in our tenant: 71 | 72 | ```powershell 73 | Invoke-EntraRequest -Path groups 74 | ``` 75 | 76 | > Certificates & Service Accounts 77 | 78 | One of the common problems when providing a certificate for a script running under a service account is, that the service account obviously has no access to our user's certificate store. 79 | So, to simplify this, we store it in the LocalMachine store, which the Service Account can access ... apparently. 80 | 81 | Still authentication keeps failing. 82 | 83 | This is usually caused by the service account not having access to the private key of the certificate. 84 | As a local admin, you can modify who can access the private key in an elevated certificate console. 85 | -------------------------------------------------------------------------------- /docs/authenticate-clientsecret.md: -------------------------------------------------------------------------------- 1 | # Application: Client Secret 2 | 3 | > [Back to Overview](overview.md) 4 | 5 | ## Configure 6 | 7 | To configure the Client Secret authentication, not much is needed. 8 | In the "Certificates & secrets" section of the App Registration, we already start on the Client secrets section by default. 9 | 10 | ![The "Certificates & secrets" section, which is a plain menu with three tabs ("Certificates", "Client secrets", "Federated credentials"), with "Client secrets" selected and an empty table beneath](pictures/04-01-DawnOfASecret.png) 11 | 12 | Select "New client secret": 13 | 14 | ![A plain web formular, titled "Add a client secret". There are two configuration options: A text field for a description and a drop-down menu for how long the secret remains valid, defaulting to 180 days](pictures/04-02-Configuration.png) 15 | 16 | Adding a description is optional but recommended. 17 | The expiration option defines, how long a secret will remain valid, with a limit of two years. 18 | Configure as needed and select "Add". 19 | 20 | ![The same "Certificates & secrets" section, but this time there is an entry in the previously empty table. An arrow points out the "Copy to Clipboard" button besides the secret value](pictures/04-03-Secret.png) 21 | 22 | We can now see the secret in the table. 23 | In the "Value" column we can now access the secret value (use the "Copy to Clipboard" button). 24 | 25 | > This is THE ONLY TIME we can access the secret. 26 | > If we do not copy it now and store it somewhere else (such as a password safe), it is gone and we can start again! 27 | 28 | That's it, we are now ready to roll. 29 | 30 | ## Authentication & Executing Queries 31 | 32 | > This example assumes that you have granted the "Group.Read.All" Graph API permission to the application. 33 | > The example on configuring API Permissions in this guide uses Delegated permissions instead, _which do not apply to Application Authentication flows!_ 34 | 35 | Using the EntraAuth PowerShell module, we can now connect using our Application, authenticating with our client secret. 36 | This example assumes the secret value is in our clipboard. 37 | 38 | ```powershell 39 | $clientID = '63a71861-498b-46ae-0000-6b5c142010e1' 40 | $tenantID = 'a948c2b3-8eb2-498a-0000-c32aeeaa0f90' 41 | $secret = Get-ClipBoard | ConvertTo-SecureString -AsPlainText -Force 42 | 43 | Connect-EntraService -ClientID $clientID -TenantID $tenantID -ClientSecret $secret 44 | ``` 45 | 46 | Once connected, we are now ready to use the connection to query all groups in our tenant: 47 | 48 | ```powershell 49 | Invoke-EntraRequest -Path groups 50 | ``` 51 | -------------------------------------------------------------------------------- /docs/authenticate-devicecode.md: -------------------------------------------------------------------------------- 1 | # Delegated: DeviceCode 2 | 3 | > [Back to Overview](overview.md) 4 | 5 | > Note: It is generally recommended to use [Interactive Browser Authentication / Authorization Code](authenticate-browser.md) 6 | 7 | ## Configure 8 | 9 | Setting up the default login via DeviceCode is fortunately not too complex: 10 | In our App Registration configuration page, we select the "Authentication" tab: 11 | 12 | ![The App Registration Configuration page for Authentication: Shows the default settings, highlighting the "Add a platform" button](pictures/02-01-Authentication.png) 13 | 14 | Select "Add a platform" from this page. 15 | 16 | ![A grid of options, what kind of platform to configure. The panel for "Web" has been highlighted](pictures/02-02-Platform.png) 17 | 18 | Choose "Web". 19 | In the follow up menu we now need to configure a Redirect Uri: 20 | 21 | ![A panel allowing us to configure "Web" redirect uris. An input textbox has already filled out with "http://localhost](pictures/02-03-Localhost.png) 22 | 23 | All we need to do now, is to add "http://localhost" and select "configure". 24 | 25 | After adding the platform, we need to scroll to the bottom and enable "Allow public client flows": 26 | 27 | ![The bottom part of the Authentication configuration menu is showing, the section "Allow public client flows" has been enabled and highlighted, the save button is blue and should be clicked](pictures/02-04-AdvancedSettings.png) 28 | 29 | Once enabled and saved, we are good to go. 30 | 31 | ## Authentication & Executing Queries 32 | 33 | Using the EntraAuth PowerShell module, we can now connect using our Application, authenticating in our Browser window: 34 | 35 | ```powershell 36 | $clientID = '63a71861-498b-46ae-0000-6b5c142010e1' 37 | $tenantID = 'a948c2b3-8eb2-498a-0000-c32aeeaa0f90' 38 | 39 | Connect-EntraService -ClientID $clientID -TenantID $tenantID -DeviceCode 40 | ``` 41 | 42 | ```text 43 | To sign in, use a web browser to open the page https://microsoft.com/devicelogin and enter the code CCXBG2F8C to authenticate. 44 | ``` 45 | 46 | Follow the guidance in the response and finish authentication. 47 | Once connected, we are now ready to use the connection to query all groups in our tenant: 48 | 49 | ```powershell 50 | Invoke-EntraRequest -Path groups 51 | ``` 52 | 53 | > This example assumes, that we followed the guide on setting up App Registrations and granted the `Group.ReadWrite.All` API permission for Microsoft Graph 54 | -------------------------------------------------------------------------------- /docs/building-on-entraauth.md: -------------------------------------------------------------------------------- 1 | # Building on EntraAuth 2 | 3 | So, let's assume you have been converted to this project and now want to migrate your module to use it. 4 | Or build a new module based on top of it. 5 | 6 | In either case, you will now be faced with some design considerations, but there is one major one that overshadows it all: 7 | 8 | > How do I deal with EntraAuth services? 9 | 10 | What is a fairly simple thing in a standalone script can suddenly become a lot more frustrating when a few issues start popping up... 11 | 12 | ## The problems we face 13 | 14 | It all starts with how we decide to deal with the services we use. 15 | There are a few common decision option ... and they all have their consequences: 16 | 17 | > 1.: Just use the defaults 18 | 19 | _Your module wants to do Graph requests? Just specify the "Graph" services, it works._ 20 | 21 | This is basically what we do in our scripts, so why not in a module? 22 | 23 | Any other module also doing this might lead to service conflicts. 24 | Imagine a script calling three separate modules. Unless the modules are called after each other only and we reconnect inbetween, this means that we need one token that meets the scope prerequisite of them all. 25 | This can be organizationally difficult. 26 | 27 | Also, if you do not control all of those modules, it becomes simple for one of them prompt the user to reconnect to another application (or even tenant!), without the user realizing this impact. 28 | This problem becomes even more troublesome as adoption of EntraAuth increases and modules take dependencies on other modules that also use EntraAuth. 29 | 30 | > 2.: Define your own service 31 | 32 | _Conflicts are bad, so let us define our own dedicated services._ 33 | 34 | Registering your own instances of services - even for services already part of the baseline - is a solid way to avoid most conflicts. 35 | 36 | The main issue with this approach is, that we now force the user to log into each service separately, which can be a bit of a bother. 37 | There are ways to "clone" delegate tokens into another service, but few users would be aware of that ... and it still needs managing. 38 | 39 | Conflict-wise, this is a fairly clean solution, but we still only have a single set of services we can use. 40 | This can be a problem if our module uses a wide range of APIs, depending on which command we call, which may use separate API permissions/scopes. 41 | 42 | A script may only want to use a limited application with fewer scopes for just what is needed, while another module using it may need a different set of scopes. 43 | Since the module can only hold a single service state / a single token per service, these will now be in conflict and force us to once again configure one large application with all scopes combined. 44 | 45 | Also, this means we do not use the default services, which is going to be unintuitive for newer users, who will not understand the entire service concept of EntraAuth (or even be aware of EntraAuth to begin with). 46 | 47 | > 3.: Define a module-wide default Service that can be changed 48 | 49 | _Why hardcode when we can give the user may chose?_ 50 | 51 | We can define a module-wide variable with the service(s) we plan to use. 52 | With that, we can allow script authors or other modules to change that and either merge or split service use as needed. 53 | 54 | This would allow a script to define, which modules should use the same service and which should go separate ways. 55 | That way, we eliminate redundant logon steps, but each module can still only have a single service configured at any given time. 56 | 57 | This would not be too bad of a problem right now, but as modules depend on other modules that use EntraAuth, this might lead to conflicts. 58 | 59 | > 4.: Expose the service to use on commands of the module 60 | 61 | If we expose the choice of service on our module's functions, each caller can pick their own service to use. 62 | Our own module becomes stateless when it comes to services used. 63 | 64 | This has the great advantage of conclusively eliminating all service/token conflicts. 65 | It also is fairly well documentable, using PowerShell command-help. 66 | 67 | Which leaves one last issue - forcing us to always specify the service is a lot more verbose and raises the minimum barrier for use. 68 | 69 | ## A hybrid approach: Module-wide default & Parameters as override 70 | 71 | The probably most viable solution to these concerns is to define a module-wide default, then in our functions offer a way to override this default. 72 | Script authors can then use the `$PSDefaultParameterValue` system variable to declutter their code. 73 | 74 | Of course, that brings some overhead in implementing this in your module, which is where EntraAuth and the ServiceSelector come in: 75 | 76 | ### Example Implementation 77 | 78 | We are building the module `ContosoTools`. 79 | In it we need to interact with the Graph API (Default service: `Graph`) and the Defender for Endpoint API (Default service: `Endpoint`) 80 | 81 | > File 1: variables.ps1 82 | 83 | This is some random file we load during our module's import. 84 | After the import is over, that's it, the file will not be run again. 85 | 86 | ```powershell 87 | $script:_services = @{ 88 | Graph = 'Graph' 89 | MDE = 'Endpoint' 90 | } 91 | 92 | $script:_serviceSelector = New-EntraServiceSelector -DefaultServices $script:_services 93 | ``` 94 | 95 | > File 2: Set-CTServiceConnection.ps1 96 | 97 | The default services to use are now defined during import - the values of the hashtable we just defined (Here: `Graph` and `Endpoint`). 98 | Now we need a convenient way for a human user to change those defaults: 99 | 100 | ```powershell 101 | function Set-CTServiceConnection { 102 | [CmdletBinding()] 103 | param ( 104 | [ArgumentCompleter({ (Get-EntraService).Name })] 105 | [string] 106 | $Graph, 107 | 108 | [ArgumentCompleter({ (Get-EntraService).Name })] 109 | [string] 110 | $Mde 111 | ) 112 | 113 | if ($Graph) { 114 | $script:_services.Graph = $Graph 115 | } 116 | if ($Mde) { 117 | $script:_services.MDE = $Mde 118 | } 119 | } 120 | ``` 121 | 122 | This allows a user to cleanly change the default services to use ... but it does not solve the conflict situation between other _modules_ trying to use our `ContosoTools`. 123 | We also need to actually use these services yet. 124 | Moving on to the actual implementation within our commands: 125 | 126 | > File 3: Get-CTUser.ps1 127 | 128 | This is just one of the many functions our module exposes to the public. 129 | In its simple form, it will return all users in the tenant (we probably want to add filtering in V2, but let's not overcomplicate this example). 130 | 131 | ```powershell 132 | function Get-CTUser { 133 | [CmdletBinding()] 134 | param ( 135 | [hashtable] 136 | $ServiceMap = @{} 137 | ) 138 | 139 | begin { 140 | $services = $script:_serviceSelector.GetServiceMap($ServiceMap) 141 | Assert-EntraConnection -Cmdlet $PSCmdlet -Service $services.Graph 142 | } 143 | process { 144 | Invoke-EntraRequest -Service $services.Graph -Path users 145 | } 146 | } 147 | ``` 148 | 149 | Let's go through this a bit: 150 | 151 | ```powershell 152 | $services = $script:_serviceSelector.GetServiceMap($ServiceMap) 153 | ``` 154 | 155 | This is the line where the real magic happens: 156 | 157 | + It will pick up the default services we defined in File 1, potentially modified by the user through the command in File 2 158 | + Then it will merge that with any explicitly bound services from `$ServiceMap` 159 | 160 | Thus a user can define their default now, without affecting other module's ability to pick their own services (and without those modules interfering with the user's choice). 161 | 162 | That's it, our module is now using EntraAuth with flexible services. 163 | Let's take a look at how this would then be used ... 164 | 165 | ### Example Use 166 | 167 | > Interactive in the console 168 | 169 | ```powershell 170 | Get-CTUser 171 | ``` 172 | 173 | ```text 174 | Get-CTUser: Not connected yet! Use Connect-EntraService to establish a connection to 'Graph' first. 175 | ``` 176 | 177 | ```powershell 178 | Connect-EntraService -ClientID Graph 179 | Get-CTUser 180 | ``` 181 | 182 | ```text 183 | < lots of results > 184 | ``` 185 | 186 | > Script 1: Simple Script 187 | 188 | A simple script that only uses our module as a dependency. 189 | 190 | ```powershell 191 | Connect-EntraService -ClientID $ClientID -TenantID $TenantID -CertificateName 'CN=MyScript' 192 | 193 | foreach ($user in Get-CTUser) { 194 | # Do Something 195 | } 196 | ``` 197 | 198 | > Script 2: Script that wants to use the Graph Beta 199 | 200 | Sometimes we just get more out of the Beta API for Microsoft Graph and the script wants to do some calls of its own. 201 | So, deciding to keep things simple, the script author wants _all_ Graph calls to go to the beta API: 202 | 203 | ```powershell 204 | Connect-EntraService Service 'GraphBeta' -ClientID $ClientID -TenantID $TenantID -CertificateName 'CN=MyScript2' 205 | $PSDefaultParameterValues['*-CT*:ServiceMap'] = @{ Graph = 'GraphBeta' } 206 | 207 | foreach ($user in Get-CTUser) { 208 | # Do Something 209 | } 210 | ``` 211 | 212 | > Script 3: Multiple modules that use EntraAuth 213 | 214 | One of our experts is writing a complex script that needs to use not just our `ContosoTools`, but also the `NorthwindUtilities` and `FabrikamRobotics` modules. 215 | These have slightly different requirements: 216 | 217 | + ContosoTools is supposed to use the default Graph v1 api, but its requests to Defender for Endpoint must go through our homebrew proxy for the Defender API. 218 | + NorthwindUtilities requires the GraphBeta and will not interact with Defender for Endpoint at all 219 | + FabrikamRobotics needs both the GraphBeta _and_ Defender for Endpoint through our proxy. 220 | 221 | All three modules are implemented based on EntraAuth and use the setup presented above. 222 | They also use consistent command prefixes (`CT`, `NW` and `FR` respectively). 223 | 224 | ```powershell 225 | $param = @{ 226 | Name = 'MDEProxy' 227 | ServiceUrl = 'https://mdeproxy.contoso.com/api' 228 | Resource = 'https://mdeproxy.contoso.com' 229 | Header = @{ 'Content-Type' = 'application/json' } 230 | } 231 | Register-EntraService @param 232 | 233 | Connect-EntraService Service 'GraphBeta', 'Graph', 'MDEProxy' -ClientID $ClientID -TenantID $TenantID -CertificateName 'CN=MyScript3' 234 | $PSDefaultParameterValues['*-CT*:ServiceMap'] = @{ MDE = 'MDEProxy' } 235 | $PSDefaultParameterValues['*-NW*:ServiceMap'] = @{ Graph = 'GraphBeta' } 236 | $PSDefaultParameterValues['*-FR*:ServiceMap'] = @{ Graph = 'GraphBeta'; MDE = 'MDEProxy' } 237 | 238 | foreach ($user in Get-CTUser) { 239 | $authDetails = Get-NWUserDetails -Id $user.id 240 | if ($authDetails.Healthy) { continue } 241 | 242 | Send-FRAuthenticationReport -Data $authDetails -Recipiemnt $user.Manager 243 | } 244 | ``` 245 | -------------------------------------------------------------------------------- /docs/creating-applications.md: -------------------------------------------------------------------------------- 1 | # Creating an Application 2 | 3 | > [Back to Overview](overview.md) 4 | 5 | |[Back to Overview](overview.md)|[Next: Application vs. Delegate Authentication](application-vs-delegate.md)| 6 | 7 | ## What the hell? 8 | 9 | At a fundamental level, an Application - or "App Registration" as it is frequently called - is a construct in Entra that describes how you connect. 10 | On an application, you configure ... 11 | 12 | + ... just how you can connect 13 | + ... who is allowed to connect 14 | + ... what rights you want to use after connecting 15 | 16 | It also allows you to troubleshoot connection issues. 17 | 18 | > Note: This is an oversimplification, but good enough for the purpose of this guide. 19 | 20 | ## Get Creating 21 | 22 | In the [Azure Portal](https://portal.azure.com) we first must select the "App registrations" option: 23 | 24 | ![Portal Screenshot](pictures/A-01-AppRegistrations.png) 25 | 26 | If it is not on the list of already listed services (which usually shows the last services opened), you can use the search bar at the top to search for it instead. 27 | 28 | Once there, we create a new App Registration: 29 | 30 | ![Use the "New registration" button](pictures/A-02-NewRegistration.png) 31 | 32 | And finally we configure our new application's basic settings: 33 | 34 | ![Select a name and leave everything else as it is](pictures/A-03-Setup.png) 35 | 36 | We need a name for this Application - and this is one of the things this guide can't really help you with: 37 | Many organizations have their own naming schemes, and you either have to figure out your organization's scheme on your own or ask a coworker in the know. 38 | 39 | Or you pick something that seems reasonable and live with the consequences - it is usually quite simple to recreate all the things we configure in an App Registration, so it is not like you will have to live for ages with a bad call here. 40 | 41 | For supported account types, leave it as it is as "Single tenant" - the other options are usually only relevant to software vendors or developers who are far more experienced than anybody reading this guide likely is and know what they do with that. 42 | 43 | Select "Register" once you are happy with your application's name. 44 | 45 | And that is it, you just created your first App Registration: 46 | 47 | ![First look into the portal view of the new App Registration](pictures/A-04-Portal.png) 48 | 49 | Note the highlighted "Application (client) ID" and "Directory (tenant) ID": 50 | Those will be needed when we try to connect later on. 51 | 52 | |[Back to Overview](overview.md)|[Next: Application vs. Delegate Authentication](application-vs-delegate.md)| 53 | -------------------------------------------------------------------------------- /docs/managing-applications.md: -------------------------------------------------------------------------------- 1 | # Managing an Application & Troubleshooting logins 2 | 3 | > [Back to Overview](overview.md) 4 | 5 | |[Previous: API Permissions and you](api-permissions.md)|[Back to Overview](overview.md)| 6 | 7 | Our application is now prepared and ready to rock! 8 | Well ... almost. 9 | 10 | We have not yet configured authentication itself. 11 | If we did, then _every_ user in the tenant could use our Application. 12 | 13 | This is usually something we want to avoid, so lot us restrict it to members of a specific group! 14 | 15 | In the overview section of our App Registration, there is a link to the manageability panel ("Enterprise Application") of our application. 16 | Not going into the details here, but it is where we configure who can use it. 17 | 18 | To do so, first select the link behind the "Managed application" section: 19 | 20 | ![Overview panel of the App registration, highlighting the section under Essentials labeled "Managed application"](pictures/D-01-Overview.png) 21 | 22 | Once in the new menu, switch to Properties: 23 | 24 | ![New settings panel, with the tab vertical "Properties" selected. The option "Assignment required?" is highlighted, but not yet enabled](pictures/D-02-Properties.png) 25 | 26 | Enable "Assignment required?" and remember to save: 27 | 28 | ![The same panel with "Assignment required?" selected, mouse hovering over the "save" button](pictures/D-03-RememberToSave.png) 29 | 30 | Alright, that done, now nobody (other than yourself) can use the Application. 31 | To add more people to it, switch to "Users and groups": 32 | 33 | ![A simple table for users & groups, with only a single entry](pictures/D-04-AssignUsersGroups.png) 34 | 35 | To add a group, select "Add user/group" at the top, opening the assignment menu: 36 | 37 | ![A simple web formular with the header "Add Assignment" and two sections - "Users and Groups" and "Select a role", the link under the latter greyed out](pictures/D-05-Selection.png) 38 | 39 | To add a group, select the "None Selected" link. 40 | 41 | > The "Select a role" option is greyed out. More complex applications could define their own permissions, allowing us to assign different permissions to different groups. 42 | > A topic for another day. 43 | 44 | ![A new panel on the right side, offering a search bar and a filtered view of results, showing the single match found to the query. At the bottom there is a blue "Select" button, but nothing has been selected yet](pictures/D-06-Selection2.png) 45 | 46 | The new panel allows us to search for any user or group in the tenant. 47 | Set the checkbox for all entries we want to add and confirm with "Select" at the bottom. 48 | 49 | ![The same old previous web formular, only this time the "Users and Groups" section shows "1 Group Selected"](pictures/D-07-Assign.png) 50 | 51 | Once done, select "Assign" to complete the assignment: 52 | 53 | ![Back in the table view of assignments, we now have two entries, one the user creating the Application and one the group just selected](pictures/D-08-Assigned.png) 54 | 55 | And that is it! 56 | We have now limited just who is allowed to use our application and the rights associated. 57 | 58 | ## Troubleshooting SignIns 59 | 60 | Not really critical for configuring our Application, if we later want to troubleshoot logon problems or check usage, there is another useful section in this menu: 61 | 62 | Sign-in logs: 63 | 64 | ![Lower in the main menu, the vertical tab "Sign-in Logs" was selected. It shows a table that will likely show signins, but is yet empty, with filter options at the top](pictures/D-09-SigninLogs.png) 65 | 66 | Nothing to see here yet, but useful if you later fail to login and can't figure out why. 67 | 68 | |[Previous: API Permissions and you](api-permissions.md)|[Back to Overview](overview.md)| 69 | -------------------------------------------------------------------------------- /docs/overview.md: -------------------------------------------------------------------------------- 1 | # Authentication Overview 2 | 3 | Welcome to the beginner's guide to Entra Authentication. 4 | Here we will try to get you up to speed on what you need to know to start executing PowerShell against services protected by Microsoft Entra authentication, such as the Graph API. 5 | 6 | ## Getting started 7 | 8 | Fundamentally, to connect to an API, you need to perform three steps: 9 | 10 | + Create an "Application" 11 | + Assign the permissions you want to use 12 | + Configure the Authentication process you want to use 13 | 14 | ## Assumptions 15 | 16 | This guide assumes you want to use PowerShell to connect to an API via Entra Authentication. 17 | The code examples assume further that you are using the Module [EntraAuth](https://github.com/FriedrichWeinmann/EntraAuth) for this purpose. 18 | 19 | The concepts and guidance also applies to other coding languages - whether you want to connect via PowerShell, Python, Java or C#, the different authentication options and the setup on the Entra side remain the same. 20 | Obviously, the code examples will not translate as well. 21 | 22 | If you are planning to build a web application or a desktop app for end users however, this guide is probably not ideal. 23 | 24 | ## Guide 25 | 26 | > General Topics 27 | 28 | + [Creating an Application](creating-applications.md) 29 | + [Application vs. Delegate Authentication](application-vs-delegate.md) 30 | + [API Permissions and you](api-permissions.md) 31 | + [Managing an Application & Troubleshooting logins](managing-applications.md) 32 | 33 | > Setting up Authentication 34 | 35 | + [Delegate: Using the Browser directly](authenticate-browser.md) 36 | + [Delegate: DeviceCode](authenticate-devicecode.md) 37 | + [Application: Certificate](authenticate-certificate.md) 38 | + [Application: Client Secret](authenticate-clientsecret.md) 39 | -------------------------------------------------------------------------------- /docs/pictures/01-01-Authentication.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FriedrichWeinmann/EntraAuth/983ceec9fffdcfa1d9ca3f556b8a02a2110f22b7/docs/pictures/01-01-Authentication.png -------------------------------------------------------------------------------- /docs/pictures/01-02-Platform.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FriedrichWeinmann/EntraAuth/983ceec9fffdcfa1d9ca3f556b8a02a2110f22b7/docs/pictures/01-02-Platform.png -------------------------------------------------------------------------------- /docs/pictures/01-03-RedirectUri.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FriedrichWeinmann/EntraAuth/983ceec9fffdcfa1d9ca3f556b8a02a2110f22b7/docs/pictures/01-03-RedirectUri.png -------------------------------------------------------------------------------- /docs/pictures/01-04-localhost.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FriedrichWeinmann/EntraAuth/983ceec9fffdcfa1d9ca3f556b8a02a2110f22b7/docs/pictures/01-04-localhost.png -------------------------------------------------------------------------------- /docs/pictures/01-05-Done.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FriedrichWeinmann/EntraAuth/983ceec9fffdcfa1d9ca3f556b8a02a2110f22b7/docs/pictures/01-05-Done.png -------------------------------------------------------------------------------- /docs/pictures/02-01-Authentication.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FriedrichWeinmann/EntraAuth/983ceec9fffdcfa1d9ca3f556b8a02a2110f22b7/docs/pictures/02-01-Authentication.png -------------------------------------------------------------------------------- /docs/pictures/02-02-Platform.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FriedrichWeinmann/EntraAuth/983ceec9fffdcfa1d9ca3f556b8a02a2110f22b7/docs/pictures/02-02-Platform.png -------------------------------------------------------------------------------- /docs/pictures/02-03-Localhost.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FriedrichWeinmann/EntraAuth/983ceec9fffdcfa1d9ca3f556b8a02a2110f22b7/docs/pictures/02-03-Localhost.png -------------------------------------------------------------------------------- /docs/pictures/02-04-AdvancedSettings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FriedrichWeinmann/EntraAuth/983ceec9fffdcfa1d9ca3f556b8a02a2110f22b7/docs/pictures/02-04-AdvancedSettings.png -------------------------------------------------------------------------------- /docs/pictures/03-01-Certificates.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FriedrichWeinmann/EntraAuth/983ceec9fffdcfa1d9ca3f556b8a02a2110f22b7/docs/pictures/03-01-Certificates.png -------------------------------------------------------------------------------- /docs/pictures/03-02-Selection.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FriedrichWeinmann/EntraAuth/983ceec9fffdcfa1d9ca3f556b8a02a2110f22b7/docs/pictures/03-02-Selection.png -------------------------------------------------------------------------------- /docs/pictures/03-03-Completed.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FriedrichWeinmann/EntraAuth/983ceec9fffdcfa1d9ca3f556b8a02a2110f22b7/docs/pictures/03-03-Completed.png -------------------------------------------------------------------------------- /docs/pictures/03-04-Finished.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FriedrichWeinmann/EntraAuth/983ceec9fffdcfa1d9ca3f556b8a02a2110f22b7/docs/pictures/03-04-Finished.png -------------------------------------------------------------------------------- /docs/pictures/04-01-DawnOfASecret.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FriedrichWeinmann/EntraAuth/983ceec9fffdcfa1d9ca3f556b8a02a2110f22b7/docs/pictures/04-01-DawnOfASecret.png -------------------------------------------------------------------------------- /docs/pictures/04-02-Configuration.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FriedrichWeinmann/EntraAuth/983ceec9fffdcfa1d9ca3f556b8a02a2110f22b7/docs/pictures/04-02-Configuration.png -------------------------------------------------------------------------------- /docs/pictures/04-03-Secret.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FriedrichWeinmann/EntraAuth/983ceec9fffdcfa1d9ca3f556b8a02a2110f22b7/docs/pictures/04-03-Secret.png -------------------------------------------------------------------------------- /docs/pictures/A-01-AppRegistrations.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FriedrichWeinmann/EntraAuth/983ceec9fffdcfa1d9ca3f556b8a02a2110f22b7/docs/pictures/A-01-AppRegistrations.png -------------------------------------------------------------------------------- /docs/pictures/A-02-NewRegistration.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FriedrichWeinmann/EntraAuth/983ceec9fffdcfa1d9ca3f556b8a02a2110f22b7/docs/pictures/A-02-NewRegistration.png -------------------------------------------------------------------------------- /docs/pictures/A-03-Setup.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FriedrichWeinmann/EntraAuth/983ceec9fffdcfa1d9ca3f556b8a02a2110f22b7/docs/pictures/A-03-Setup.png -------------------------------------------------------------------------------- /docs/pictures/A-04-Portal.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FriedrichWeinmann/EntraAuth/983ceec9fffdcfa1d9ca3f556b8a02a2110f22b7/docs/pictures/A-04-Portal.png -------------------------------------------------------------------------------- /docs/pictures/C-01-ApiPermission-Portal.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FriedrichWeinmann/EntraAuth/983ceec9fffdcfa1d9ca3f556b8a02a2110f22b7/docs/pictures/C-01-ApiPermission-Portal.png -------------------------------------------------------------------------------- /docs/pictures/C-02-RequestPermissions.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FriedrichWeinmann/EntraAuth/983ceec9fffdcfa1d9ca3f556b8a02a2110f22b7/docs/pictures/C-02-RequestPermissions.png -------------------------------------------------------------------------------- /docs/pictures/C-03-ApplicationDelegate.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FriedrichWeinmann/EntraAuth/983ceec9fffdcfa1d9ca3f556b8a02a2110f22b7/docs/pictures/C-03-ApplicationDelegate.png -------------------------------------------------------------------------------- /docs/pictures/C-04-ScopesFilter.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FriedrichWeinmann/EntraAuth/983ceec9fffdcfa1d9ca3f556b8a02a2110f22b7/docs/pictures/C-04-ScopesFilter.png -------------------------------------------------------------------------------- /docs/pictures/C-05-ScopesAssign.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FriedrichWeinmann/EntraAuth/983ceec9fffdcfa1d9ca3f556b8a02a2110f22b7/docs/pictures/C-05-ScopesAssign.png -------------------------------------------------------------------------------- /docs/pictures/C-06-ConsentPending.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FriedrichWeinmann/EntraAuth/983ceec9fffdcfa1d9ca3f556b8a02a2110f22b7/docs/pictures/C-06-ConsentPending.png -------------------------------------------------------------------------------- /docs/pictures/C-07-ConsentGranting.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FriedrichWeinmann/EntraAuth/983ceec9fffdcfa1d9ca3f556b8a02a2110f22b7/docs/pictures/C-07-ConsentGranting.png -------------------------------------------------------------------------------- /docs/pictures/C-08-ConsentGranted.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FriedrichWeinmann/EntraAuth/983ceec9fffdcfa1d9ca3f556b8a02a2110f22b7/docs/pictures/C-08-ConsentGranted.png -------------------------------------------------------------------------------- /docs/pictures/C-09-UnknownService.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FriedrichWeinmann/EntraAuth/983ceec9fffdcfa1d9ca3f556b8a02a2110f22b7/docs/pictures/C-09-UnknownService.png -------------------------------------------------------------------------------- /docs/pictures/C-10-SearchingService.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FriedrichWeinmann/EntraAuth/983ceec9fffdcfa1d9ca3f556b8a02a2110f22b7/docs/pictures/C-10-SearchingService.png -------------------------------------------------------------------------------- /docs/pictures/D-01-Overview.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FriedrichWeinmann/EntraAuth/983ceec9fffdcfa1d9ca3f556b8a02a2110f22b7/docs/pictures/D-01-Overview.png -------------------------------------------------------------------------------- /docs/pictures/D-02-Properties.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FriedrichWeinmann/EntraAuth/983ceec9fffdcfa1d9ca3f556b8a02a2110f22b7/docs/pictures/D-02-Properties.png -------------------------------------------------------------------------------- /docs/pictures/D-03-RememberToSave.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FriedrichWeinmann/EntraAuth/983ceec9fffdcfa1d9ca3f556b8a02a2110f22b7/docs/pictures/D-03-RememberToSave.png -------------------------------------------------------------------------------- /docs/pictures/D-04-AssignUsersGroups.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FriedrichWeinmann/EntraAuth/983ceec9fffdcfa1d9ca3f556b8a02a2110f22b7/docs/pictures/D-04-AssignUsersGroups.png -------------------------------------------------------------------------------- /docs/pictures/D-05-Selection.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FriedrichWeinmann/EntraAuth/983ceec9fffdcfa1d9ca3f556b8a02a2110f22b7/docs/pictures/D-05-Selection.png -------------------------------------------------------------------------------- /docs/pictures/D-06-Selection2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FriedrichWeinmann/EntraAuth/983ceec9fffdcfa1d9ca3f556b8a02a2110f22b7/docs/pictures/D-06-Selection2.png -------------------------------------------------------------------------------- /docs/pictures/D-07-Assign.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FriedrichWeinmann/EntraAuth/983ceec9fffdcfa1d9ca3f556b8a02a2110f22b7/docs/pictures/D-07-Assign.png -------------------------------------------------------------------------------- /docs/pictures/D-08-Assigned.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FriedrichWeinmann/EntraAuth/983ceec9fffdcfa1d9ca3f556b8a02a2110f22b7/docs/pictures/D-08-Assigned.png -------------------------------------------------------------------------------- /docs/pictures/D-09-SigninLogs.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/FriedrichWeinmann/EntraAuth/983ceec9fffdcfa1d9ca3f556b8a02a2110f22b7/docs/pictures/D-09-SigninLogs.png -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- 1 | # Entra Authentication Module 2 | 3 | Welcome to the Entra Authentication Module project. 4 | Your one stop for authenticating to any service behind Microsoft Entra Authentication. 5 | 6 | Whether you just want a token ... or for someone to deal with all of the nasty details of executing API requests. 7 | 8 | Functionally, if you liked the good old MSAL.PS and are looking for a successor, look no further. 9 | 10 | ## Installing 11 | 12 | To use this module, run the following command: 13 | 14 | ```powershell 15 | Install-Module EntraAuth -Scope CurrentUser 16 | ``` 17 | 18 | Or if you have PowerShell 7.4 or later: 19 | 20 | ```powershell 21 | Install-PSResource EntraAuth 22 | ``` 23 | 24 | ## Overview 25 | 26 | To profit from the module, you basically ... 27 | 28 | + Connect to a service 29 | + Then execute requests against it or retrieve its token 30 | 31 | Some common services come preconfigured (e.g. Graph, GraphBeta or the Security API), for others you might first need to register the service. 32 | 33 | > Note for module developers: There is a dedicated chapter at the bottom with important advice. 34 | 35 | ## Quickstart to Graph 36 | 37 | While this module is intended to interact with many APIs, the Graph API is by far the most common need. 38 | So, let's get started with the right away: 39 | 40 | ```powershell 41 | # Connect via default PowerShell Graph application 42 | Connect-EntraService -ClientID Graph -Scopes User.ReadBasic.All 43 | 44 | # Read info about current user 45 | Invoke-EntraRequest -Path me 46 | 47 | # List all users 48 | Invoke-EntraRequest -Path users 49 | ``` 50 | 51 | There is a lot more to authenticating - especially once the default applications no longer suffice and we need to talk to more than just Graph. 52 | See below for the nitty-gritty "How the hell do I make this work?!" details. 53 | 54 | ## Preparing to Authenticate 55 | 56 | For those new to connecting to and executing against APIs that require Entra authentication, we have prepared a guide, explaining the different authentication options, which to chose when and what you need to do to prepare outside of the code. 57 | 58 | > [Guide to Authentication](docs/overview.md) 59 | 60 | ## Connect 61 | 62 | To connect you usually need a ClientID and a TenantID for the App Registration you are using for logon. 63 | Depending on how you want to authenticate, this App Registration may need some configuration: 64 | 65 | + Browser (default): In the `Authentication` tab in the portal, register a 'Mobile and Desktop Applications' Platform with the 'http://localhost' redirect uri. 66 | + DeviceCode: In the `Authentication` tab in the portal, register a 'Web' Platform with the 'http://localhost' redirect uri and enable `Allow public client flows`. 67 | + ClientSecret: In the `certificate & secrets` tab create a secret and provide it as a SecureString when connecting 68 | + Certificate: In the `certificate & secrets` tab register a certificate and provide it when connecting 69 | 70 | Example connect calls for each flow: 71 | 72 | ```powershell 73 | # Example values, fill in the appropriate ones from your App Registration 74 | $ClientID = 'd6a3ffb9-6217-40d6-bfb2-f5769b65970a' 75 | $TenantID = 'a948c2b3-8eb2-498a-9108-c32aeeaa0f97' 76 | 77 | # Browser Based 78 | Connect-EntraService -ClientID $ClientID -TenantID $TenantID 79 | Connect-EntraService -ClientID $ClientID -TenantID $TenantID -Service Endpoint 80 | 81 | # DeviceCode authentication 82 | Connect-EntraService -ClientID $ClientID -TenantID $TenantID -DeviceCode 83 | Connect-EntraService -ClientID $ClientID -TenantID $TenantID -DeviceCode -Service Endpoint 84 | 85 | # Client Secret Based 86 | $secret = Get-ClipBoard | ConvertTo-SecureString -AsPlainText -Force # Assuming the secret is in your clipboard 87 | Connect-EntraService -ClientID $ClientID -TenantID $TenantID -ClientSecret $secret 88 | Connect-EntraService -ClientID $ClientID -TenantID $TenantID -ClientSecret $secret -Service Endpoint 89 | 90 | ## Certificate Based 91 | Connect-EntraService -ClientID $ClientID -TenantID $TenantID -Certificate $cert 92 | Connect-EntraService -ClientID $ClientID -TenantID $TenantID -CertificateThumbprint E1AE5158CA92CC9AA53D955217567B30E68647BD 93 | Connect-EntraService -ClientID $ClientID -TenantID $TenantID -CertificateName 'CN=Whatever' 94 | ``` 95 | 96 | > Azure Key Vault integration 97 | 98 | It is possible to directly read a Certificate or Client Secret from an Azure Key Vault und use it for authentication. 99 | In order for this to work, an already established connection to Azure KeyVault is required: 100 | 101 | ```powershell 102 | # Option 1: Az.Accounts 103 | Connect-AzAccount 104 | 105 | # Option 2: EntraAuth integrated KeyVault service 106 | # App Registration used must have the delegate Key Vault scope "user_impersonation" 107 | Connect-EntraService -ClientID $ClientID -TenantID $TenantID -Service AzureKeyVault 108 | ``` 109 | 110 | Once Key Vault access is established, this one line will retrieve the secret from Key Vault - no matter whether a certificate or a Client Secret - and complete the authentication. When connected like that, it will retrieve the secret from Key Vault again once the token expires. 111 | 112 | ```powershell 113 | # Direct Azure KeyVault integration with Certificate or Client Secret 114 | Connect-EntraService -ClientID $ClientID -TenantID $TenantID -VaultName myVault -SecretName mySecret 115 | ``` 116 | 117 | > Managed Identity 118 | 119 | It is also possible to connect using a Managed Identity (for example from within an Azure Function App): 120 | 121 | ```powershell 122 | # Connect to graph using an MSI 123 | Connect-EntraService -Identity 124 | 125 | # Connect to Azure Key Vault using an MSI 126 | Connect-EntraService -Identity -Service AzureKeyVault 127 | ``` 128 | 129 | It is also possible to connect via User-Assigned Managed Identity: 130 | 131 | ```powershell 132 | # Using the Client ID of the User-Assigned Managed Identity 133 | Connect-EntraService -Identity -IdentityID $miClientID 134 | 135 | # Using the Principal ID of the User-Assigned Managed Identity 136 | Connect-EntraService -Identity -IdentityID $princpalID -IdentityType PrincipalID 137 | 138 | # Using the Azure Resource ID of the User-Assigned Managed Identity 139 | Connect-EntraService -Identity -IdentityType ResourceID -IdentityID '/subscriptions//resourcegroups//providers/Microsoft.ManagedIdentity/userAssignedIdentities/' 140 | ``` 141 | 142 | > Default Service 143 | 144 | By default, the service connected to is the Microsoft Graph API. 145 | The same default is also used for requests. 146 | To change the default, you can use the `-MakeDefault` parameter when connecting: 147 | 148 | ```powershell 149 | Connect-EntraService -ClientID $ClientID -TenantID $TenantID -Service Endpoint -MakeDefault 150 | ``` 151 | 152 | This would make the `Endpoint` service the new default service for new connections or requests. 153 | 154 | > Multiple Services 155 | 156 | It is quite possible to be connected to multiple services in parallel. 157 | Even if you use the same app registration for both services, you need to connect for each service individually. 158 | You can however perform all connections using the same app registration in the same call: 159 | 160 | ```powershell 161 | Connect-EntraService -ClientID $ClientID -TenantID $TenantID -ClientSecret $secret -Service Endpoint, Graph 162 | ``` 163 | 164 | > Graph & Graph Beta 165 | 166 | The Graph and the Graph Beta are registered as separate services. 167 | However, both can theoretically use the same token data ... but this module can't unify them properly without additional inconsistencies. 168 | 169 | However, when specifying the request (see below), rather than providing the relative api path, you can provide the full http-starting url instead. 170 | So if you mostly want to use v1.0 but have this one request that must be made in beta, you can specify the full url for that call and don't need separate connections. 171 | 172 | ## Requests 173 | 174 | Once connected to a service, executing requests against that service becomes quite simple: 175 | 176 | ```powershell 177 | # Request information from the default service 178 | Invoke-EntraRequest -Path me 179 | 180 | # Request information from the GraphBeta service 181 | Invoke-EntraRequest -Service GraphBeta -Path me 182 | ``` 183 | 184 | > Query Modifiers 185 | 186 | You can modify requests by adding query parameters. 187 | Either you specify them in your path, or you use the `-Query` parameter: 188 | 189 | ```powershell 190 | Invoke-EntraRequest -Path 'users?$select=displayName,givenName,id' 191 | 192 | Invoke-EntraRequest -Path users -Query @{ 193 | '$select' = 'displayName', 'givenName', 'id' 194 | } 195 | ``` 196 | 197 | ## Using the Token 198 | 199 | Sometimes you want direct access to the token and just do your own thing. 200 | There are two ways to get a token: 201 | 202 | + Ask for it during the request 203 | + Retrieve it after connecting 204 | 205 | All tokens are maintained in the module for its runtime, but it will only maintain the latest iteration for a single service. 206 | 207 | ```powershell 208 | # During the Connectiong 209 | $token = Connect-EntraService -ClientID $ClientID -TenantID $TenantID -Service GraphBeta -PassThru 210 | 211 | # After already being connected 212 | $token = Get-EntraToken -Service GraphBeta 213 | ``` 214 | 215 | Once obtained, a token can be used either in `Invoke-EntraRequest` or in your own code: 216 | 217 | ```powershell 218 | # Reuse token 219 | Invoke-EntraRequest -Path me -Token $token 220 | 221 | # Get Authentication header and use that 222 | Invoke-RestMethod -Uri 'https://graph.microsoft.com/v1.0/users' -Headers $token.GetHeader() 223 | ``` 224 | 225 | > The `Getheader()` method will automatically refresh expiring tokens if needed. 226 | > Directly accessing the `.AccessToken` property is possible, but will not refresh tokens. 227 | 228 | ## Registering Services 229 | 230 | So, that whole thing is all nice and everything, but ... what if we want a token for a service not prepared in the module? 231 | What if it's an app that only exists in your own tenant? 232 | What if it's a function app only your team uses? 233 | Or some Microsoft Product that was released a year after this module and we just never updated it? 234 | 235 | Well, that is where our `*-EntraService` commands come in: 236 | 237 | + `Get-EntraService` to see all currently configured services 238 | + `Register-EntraService` to add new services 239 | + `Set-EntraService` to modify the configuration of an existing service 240 | 241 | The main one is `Register-EntraService`: 242 | 243 | ```powershell 244 | $graphCfg = @{ 245 | Name = 'Graph' 246 | ServiceUrl = 'https://graph.microsoft.com/v1.0' 247 | Resource = 'https://graph.microsoft.com' 248 | DefaultScopes = @() 249 | HelpUrl = 'https://developer.microsoft.com/en-us/graph/quick-start' 250 | Header = @{ } 251 | NoRefresh = $false 252 | } 253 | Register-EntraService @graphCfg 254 | ``` 255 | 256 | > Name 257 | 258 | The name the service is referenced by. 259 | 260 | > Service Url 261 | 262 | The base url all request from `Invoke-EntraRequest` using the service use, unless their requests specify the full web url. 263 | If your API calls look like this: 264 | 265 | ```text 266 | https://graph.microsoft.com/v1.0/users 267 | https://graph.microsoft.com/v1.0/me 268 | https://graph.microsoft.com/v1.0/messages 269 | ``` 270 | 271 | Then `https://graph.microsoft.com/v1.0` would be the Service url. 272 | Effectively, you must provide any Url element after this value. 273 | 274 | This property only matters when you use `Invoke-EntraRequest` or directly read it off the token properties. 275 | 276 | > Resource 277 | 278 | This is the ID of the resource connecting to. 279 | To figure out the value needed here, go to the `API Permission` tab on the App Registration and click on the respective header in your list of scopes (e.g. `Microsoft Graph (##)`): 280 | The value under the display name at the top is the Resource. 281 | This could be a url such as `https://graph.microsoft.com` but can also be something like `api://` and has no fixed relationship to the requests URLs. 282 | 283 | > Default Scopes 284 | 285 | In delegate mode, we sometimes ask for what scopes we need (which may lead to users being prompted to consent them). 286 | If a service is a bit pointless to use without some minimal scopes and you want to make it more comfortable to use, you can provide the default set of scopes here. 287 | If the user does not specify any scopes during the connect, then at least these are asked for. 288 | 289 | + Not used in application authentication flow or for the ROPC flow 290 | + Can lead to failure if the application does not have these scopes configured 291 | 292 | > Help Url 293 | 294 | Pure documentation in case you want to help users figure out how to use your service. 295 | 296 | > Header 297 | 298 | Additional values to include in the header for all requests. 299 | For example, if you always must specify a specific content-type, this can be included here. 300 | 301 | > NoRefresh 302 | 303 | Tokens only last so long. 304 | With a certificate or a Client Secret, that's no problem - just silently do the authentication again and that's it. 305 | However, interactive logons using the browser would then force the user to logon again and again. 306 | 307 | To make this less painful, a refresh token can be requested on the first interactive logon, allowing the silent renewal of tokens. 308 | This is done automatically by default, so no need to meddle with that. 309 | Usually. 310 | 311 | Some services may not support this and some security policies interfere as well for administrative accounts, so refresh tokens may not be desired. 312 | Configuring a service to not refresh means interactive logons will prompt again once the token has expired. 313 | 314 | ## Module building on EntraAuth 315 | 316 | Find the latest guidance to implementing EntraAuth [in our dedicated docs page for that very topic](docs/building-on-entraauth.md). 317 | -------------------------------------------------------------------------------- /tests/functions/New-EntraFilterBuilder.tests.ps1: -------------------------------------------------------------------------------- 1 | Describe "Testing the command New-EntraFilterBuilder" -Tag unit { 2 | It "Will create a simple condition" { 3 | $filterBuilder = New-EntraFilterBuilder 4 | $filterBuilder.Add('name', 'eq', 'Fred') 5 | $filterBuilder.Get() | Should -Be "name eq 'Fred'" 6 | } 7 | It "Will create a simple filter query" { 8 | $filterBuilder = New-EntraFilterBuilder 9 | $filterBuilder.Add('name', 'eq', 'Fred') 10 | $filterBuilder.GetHeader().'$filter' | Should -Be "name eq 'Fred'" 11 | $filterBuilder.GetHeader().Count | Should -Be 1 12 | } 13 | 14 | It "Will merge conditions with an AND condition" { 15 | $filterBuilder = New-EntraFilterBuilder 16 | $filterBuilder.Add('name', 'eq', 'Fred') 17 | $filterBuilder.Add('country', 'eq', 'Germany') 18 | $filterBuilder.Get() | Should -Be "name eq 'Fred' and country eq 'Germany'" 19 | } 20 | It "Will merge conditions with an OR condition" { 21 | $filterBuilder = New-EntraFilterBuilder -Logic OR 22 | $filterBuilder.Add('name', 'eq', 'Fred') 23 | $filterBuilder.Add('name', 'eq', 'Max') 24 | $filterBuilder.Get() | Should -Be "name eq 'Fred' or name eq 'Max'" 25 | } 26 | 27 | It "Will support wildcard filtering correctly" { 28 | $filterBuilder = New-EntraFilterBuilder 29 | $filterBuilder.Add('displayName','eq','dept-000-*') 30 | $filterBuilder.Get() | Should -Be "startswith(displayName, 'dept-000-')" 31 | 32 | $filterBuilder = New-EntraFilterBuilder 33 | $filterBuilder.Add('displayName','eq','*-admin') 34 | $filterBuilder.Get() | Should -Be "endswith(displayName, '-admin')" 35 | } 36 | It "Will support searching in a multivalue field" { 37 | $filterBuilder = New-EntraFilterBuilder 38 | $filterBuilder.Add('servicePrincipalName', 'any', 'abc@contoso.com') 39 | $filterBuilder.Get() | Should -Be "servicePrincipalName/any(x:x eq 'abc@contoso.com')" 40 | } 41 | It "Will support matching against multiple values" { 42 | $filterBuilder = New-EntraFilterBuilder 43 | $filterBuilder.Add('name','in',@('Fred','Max')) 44 | $filterBuilder.Get() | Should -Be "name in ('Fred', 'Max')" 45 | } 46 | 47 | It "Will support nested filter builders & custom filters in the correct order" { 48 | $filterBuilder = New-EntraFilterBuilder 49 | $filterBuilder.Add('enabled', 'eq', $true) 50 | 51 | $filterBuilder2 = New-EntraFilterBuilder -Logic OR 52 | $filterBuilder2.Add('name','eq','Fred') 53 | $filterBuilder2.Add('name','eq','Max') 54 | $filterBuilder.Add($filterBuilder2) 55 | 56 | $filterBuilder.Add('(abc eq 42)') 57 | 58 | $filterBuilder.Get() | Should -Be "enabled eq True and (name eq 'Fred' or name eq 'Max') and (abc eq 42)" 59 | } 60 | } -------------------------------------------------------------------------------- /tests/functions/readme.md: -------------------------------------------------------------------------------- 1 | # Description 2 | 3 | This is where the function tests go. 4 | 5 | Make sure to put them in folders reflecting the actual module structure. 6 | 7 | It is not necessary to differentiate between internal and public functions here. -------------------------------------------------------------------------------- /tests/general/FileIntegrity.Exceptions.ps1: -------------------------------------------------------------------------------- 1 | # List of forbidden commands 2 | $global:BannedCommands = @( 3 | 'Write-Output' 4 | 5 | # Use CIM instead where possible 6 | 'Get-WmiObject' 7 | 'Invoke-WmiMethod' 8 | 'Register-WmiEvent' 9 | 'Remove-WmiObject' 10 | 'Set-WmiInstance' 11 | 12 | # Use Get-WinEvent instead 13 | 'Get-EventLog' 14 | 15 | # User Preference should not be used in automation 16 | 'Clear-Host' # Console Screen belongs to the user 17 | 'Set-Location' # Console path belongs to the user. Use $PSScriptRoot instead. 18 | 19 | # Dynamic Variables are undesirable. Use hashtable instead. 20 | 'Get-Variable' 21 | 'Set-Variable' 22 | 'Clear-Variable' 23 | 'Remove-Variable' 24 | 'New-Variable' 25 | 26 | # Dynamic Code execution should not require this 27 | 'Invoke-Expression' # Consider splatting instead. Yes, you can splat parameters for external applications! 28 | ) 29 | 30 | <# 31 | Contains list of exceptions for banned cmdlets. 32 | Insert the file names of files that may contain them. 33 | 34 | Example: 35 | "Write-Host" = @('Write-PSFHostColor.ps1','Write-PSFMessage.ps1') 36 | #> 37 | $global:MayContainCommand = @{ 38 | "Write-Host" = @() 39 | "Write-Verbose" = @() 40 | "Write-Warning" = @() 41 | "Write-Error" = @() 42 | "Write-Output" = @() 43 | "Write-Information" = @() 44 | "Write-Debug" = @() 45 | } -------------------------------------------------------------------------------- /tests/general/FileIntegrity.Tests.ps1: -------------------------------------------------------------------------------- 1 | $moduleRoot = (Resolve-Path "$global:testroot\..").Path 2 | 3 | . "$global:testroot\general\FileIntegrity.Exceptions.ps1" 4 | 5 | Describe "Verifying integrity of module files" { 6 | BeforeAll { 7 | function Get-FileEncoding 8 | { 9 | <# 10 | .SYNOPSIS 11 | Tests a file for encoding. 12 | 13 | .DESCRIPTION 14 | Tests a file for encoding. 15 | 16 | .PARAMETER Path 17 | The file to test 18 | #> 19 | [CmdletBinding()] 20 | Param ( 21 | [Parameter(Mandatory = $True, ValueFromPipelineByPropertyName = $True)] 22 | [Alias('FullName')] 23 | [string] 24 | $Path 25 | ) 26 | 27 | if ($PSVersionTable.PSVersion.Major -lt 6) 28 | { 29 | [byte[]]$byte = get-content -Encoding byte -ReadCount 4 -TotalCount 4 -Path $Path 30 | } 31 | else 32 | { 33 | [byte[]]$byte = Get-Content -AsByteStream -ReadCount 4 -TotalCount 4 -Path $Path 34 | } 35 | 36 | if ($byte[0] -eq 0xef -and $byte[1] -eq 0xbb -and $byte[2] -eq 0xbf) { 'UTF8 BOM' } 37 | elseif ($byte[0] -eq 0xfe -and $byte[1] -eq 0xff) { 'Unicode' } 38 | elseif ($byte[0] -eq 0 -and $byte[1] -eq 0 -and $byte[2] -eq 0xfe -and $byte[3] -eq 0xff) { 'UTF32' } 39 | elseif ($byte[0] -eq 0x2b -and $byte[1] -eq 0x2f -and $byte[2] -eq 0x76) { 'UTF7' } 40 | else { 'Unknown' } 41 | } 42 | } 43 | 44 | Context "Validating PS1 Script files" { 45 | $allFiles = Get-ChildItem -Path $moduleRoot -Recurse | Where-Object Name -like "*.ps1" | Where-Object FullName -NotLike "$moduleRoot\tests\*" 46 | 47 | foreach ($file in $allFiles) 48 | { 49 | $name = $file.FullName.Replace("$moduleRoot\", '') 50 | 51 | It "[$name] Should have UTF8 encoding with Byte Order Mark" -TestCases @{ file = $file } { 52 | Get-FileEncoding -Path $file.FullName | Should -Be 'UTF8 BOM' 53 | } 54 | 55 | It "[$name] Should have no trailing space" -TestCases @{ file = $file } { 56 | ($file | Select-String "\s$" | Where-Object { $_.Line.Trim().Length -gt 0}).LineNumber | Should -BeNullOrEmpty 57 | } 58 | 59 | $tokens = $null 60 | $parseErrors = $null 61 | $null = [System.Management.Automation.Language.Parser]::ParseFile($file.FullName, [ref]$tokens, [ref]$parseErrors) 62 | 63 | It "[$name] Should have no syntax errors" -TestCases @{ parseErrors = $parseErrors } { 64 | $parseErrors | Should -BeNullOrEmpty 65 | } 66 | 67 | foreach ($command in $global:BannedCommands) 68 | { 69 | if ($global:MayContainCommand["$command"] -notcontains $file.Name) 70 | { 71 | It "[$name] Should not use $command" -TestCases @{ tokens = $tokens; command = $command } { 72 | $tokens | Where-Object Text -EQ $command | Should -BeNullOrEmpty 73 | } 74 | } 75 | } 76 | } 77 | } 78 | 79 | Context "Validating help.txt help files" { 80 | $allFiles = Get-ChildItem -Path $moduleRoot -Recurse | Where-Object Name -like "*.help.txt" | Where-Object FullName -NotLike "$moduleRoot\tests\*" 81 | 82 | foreach ($file in $allFiles) 83 | { 84 | $name = $file.FullName.Replace("$moduleRoot\", '') 85 | 86 | It "[$name] Should have UTF8 encoding" -TestCases @{ file = $file } { 87 | Get-FileEncoding -Path $file.FullName | Should -Be 'UTF8 BOM' 88 | } 89 | 90 | It "[$name] Should have no trailing space" -TestCases @{ file = $file } { 91 | ($file | Select-String "\s$" | Where-Object { $_.Line.Trim().Length -gt 0 } | Measure-Object).Count | Should -Be 0 92 | } 93 | } 94 | } 95 | } -------------------------------------------------------------------------------- /tests/general/Help.Exceptions.ps1: -------------------------------------------------------------------------------- 1 | # List of functions that should be ignored 2 | $global:FunctionHelpTestExceptions = @( 3 | 4 | ) 5 | 6 | <# 7 | List of arrayed enumerations. These need to be treated differently. Add full name. 8 | Example: 9 | 10 | "Sqlcollaborative.Dbatools.Connection.ManagementConnectionType[]" 11 | #> 12 | $global:HelpTestEnumeratedArrays = @( 13 | 14 | ) 15 | 16 | <# 17 | Some types on parameters just fail their validation no matter what. 18 | For those it becomes possible to skip them, by adding them to this hashtable. 19 | Add by following this convention: = @() 20 | Example: 21 | 22 | "Get-DbaCmObject" = @("DoNotUse") 23 | #> 24 | $global:HelpTestSkipParameterType = @{ 25 | 26 | } 27 | -------------------------------------------------------------------------------- /tests/general/Help.Tests.ps1: -------------------------------------------------------------------------------- 1 | <# 2 | .NOTES 3 | The original test this is based upon was written by June Blender. 4 | After several rounds of modifications it stands now as it is, but the honor remains hers. 5 | 6 | Thank you June, for all you have done! 7 | 8 | .DESCRIPTION 9 | This test evaluates the help for all commands in a module. 10 | 11 | .PARAMETER SkipTest 12 | Disables this test. 13 | 14 | .PARAMETER CommandPath 15 | List of paths under which the script files are stored. 16 | This test assumes that all functions have their own file that is named after themselves. 17 | These paths are used to search for commands that should exist and be tested. 18 | Will search recursively and accepts wildcards, make sure only functions are found 19 | 20 | .PARAMETER ModuleName 21 | Name of the module to be tested. 22 | The module must already be imported 23 | 24 | .PARAMETER ExceptionsFile 25 | File in which exceptions and adjustments are configured. 26 | In it there should be two arrays and a hashtable defined: 27 | $global:FunctionHelpTestExceptions 28 | $global:HelpTestEnumeratedArrays 29 | $global:HelpTestSkipParameterType 30 | These can be used to tweak the tests slightly in cases of need. 31 | See the example file for explanations on each of these usage and effect. 32 | #> 33 | [CmdletBinding()] 34 | Param ( 35 | [switch] 36 | $SkipTest, 37 | 38 | [string[]] 39 | $CommandPath = @("$global:testroot\..\EntraAuth\functions", "$global:testroot\..\EntraAuth\internal\functions"), 40 | 41 | [string] 42 | $ModuleName = "EntraAuth", 43 | 44 | [string] 45 | $ExceptionsFile = "$global:testroot\general\Help.Exceptions.ps1" 46 | ) 47 | if ($SkipTest) { return } 48 | . $ExceptionsFile 49 | 50 | $CommandPath = @( 51 | "$global:testroot\..\EntraAuth\functions" 52 | "$global:testroot\..\EntraAuth\internal\functions" 53 | ) 54 | 55 | $includedNames = foreach ($path in $CommandPath) { (Get-ChildItem $path -Recurse -File | Where-Object Name -like "*.ps1").BaseName } 56 | $commandTypes = @('Cmdlet', 'Function') 57 | if ($PSVersionTable.PSEdition -eq 'Desktop' ) { $commandTypes += 'Workflow' } 58 | $commands = Get-Command -Module (Get-Module $ModuleName) -CommandType $commandTypes | Where-Object Name -In $includedNames 59 | 60 | ## When testing help, remember that help is cached at the beginning of each session. 61 | ## To test, restart session. 62 | 63 | 64 | foreach ($command in $commands) { 65 | $commandName = $command.Name 66 | 67 | # Skip all functions that are on the exclusions list 68 | if ($global:FunctionHelpTestExceptions -contains $commandName) { continue } 69 | 70 | # The module-qualified command fails on Microsoft.PowerShell.Archive cmdlets 71 | $Help = Get-Help $commandName -ErrorAction SilentlyContinue 72 | 73 | Describe "Test help for $commandName" { 74 | 75 | # If help is not found, synopsis in auto-generated help is the syntax diagram 76 | It "should not be auto-generated" -TestCases @{ Help = $Help } { 77 | $Help.Synopsis | Should -Not -BeLike '*`[``]*' 78 | } 79 | 80 | # Should be a description for every function 81 | It "gets description for $commandName" -TestCases @{ Help = $Help } { 82 | $Help.Description | Should -Not -BeNullOrEmpty 83 | } 84 | 85 | # Should be at least one example 86 | It "gets example code from $commandName" -TestCases @{ Help = $Help } { 87 | ($Help.Examples.Example | Select-Object -First 1).Code | Should -Not -BeNullOrEmpty 88 | } 89 | 90 | # Should be at least one example description 91 | It "gets example help from $commandName" -TestCases @{ Help = $Help } { 92 | ($Help.Examples.Example.Remarks | Select-Object -First 1).Text | Should -Not -BeNullOrEmpty 93 | } 94 | 95 | Context "Test parameter help for $commandName" { 96 | 97 | $common = 'Debug', 'ErrorAction', 'ErrorVariable', 'InformationAction', 'InformationVariable', 'OutBuffer', 'OutVariable', 'PipelineVariable', 'Verbose', 'WarningAction', 'WarningVariable', 'ProgressAction' 98 | 99 | $parameters = $command.ParameterSets.Parameters | Sort-Object -Property Name -Unique | Where-Object Name -notin $common 100 | $parameterNames = $parameters.Name 101 | $HelpParameterNames = $Help.Parameters.Parameter.Name | Sort-Object -Unique 102 | foreach ($parameter in $parameters) { 103 | $parameterName = $parameter.Name 104 | $parameterHelp = $Help.parameters.parameter | Where-Object Name -EQ $parameterName 105 | 106 | # Should be a description for every parameter 107 | It "gets help for parameter: $parameterName : in $commandName" -TestCases @{ parameterHelp = $parameterHelp } { 108 | $parameterHelp.Description.Text | Should -Not -BeNullOrEmpty 109 | } 110 | 111 | if ($HelpTestSkipParameterType[$commandName] -contains $parameterName) { continue } 112 | 113 | $codeType = $parameter.ParameterType.Name 114 | 115 | if ($parameter.ParameterType.IsEnum) { 116 | # Enumerations often have issues with the typename not being reliably available 117 | $names = $parameter.ParameterType::GetNames($parameter.ParameterType) 118 | # Parameter type in Help should match code 119 | It "help for $commandName has correct parameter type for $parameterName" -TestCases @{ parameterHelp = $parameterHelp; names = $names } { 120 | $parameterHelp.parameterValueGroup.parameterValue | Should -be $names 121 | } 122 | } 123 | elseif ($parameter.ParameterType.FullName -in $HelpTestEnumeratedArrays) { 124 | # Enumerations often have issues with the typename not being reliably available 125 | $names = [Enum]::GetNames($parameter.ParameterType.DeclaredMembers[0].ReturnType) 126 | It "help for $commandName has correct parameter type for $parameterName" -TestCases @{ parameterHelp = $parameterHelp; names = $names } { 127 | $parameterHelp.parameterValueGroup.parameterValue | Should -be $names 128 | } 129 | } 130 | else { 131 | # To avoid calling Trim method on a null object. 132 | $helpType = if ($parameterHelp.parameterValue) { $parameterHelp.parameterValue.Trim() } 133 | # Parameter type in Help should match code 134 | It "help for $commandName has correct parameter type for $parameterName" -TestCases @{ helpType = $helpType; codeType = $codeType } { 135 | $helpType | Should -be $codeType 136 | } 137 | } 138 | } 139 | foreach ($helpParm in $HelpParameterNames) { 140 | # Shouldn't find extra parameters in help. 141 | It "finds help parameter in code: $helpParm" -TestCases @{ helpParm = $helpParm; parameterNames = $parameterNames } { 142 | $helpParm -in $parameterNames | Should -Be $true 143 | } 144 | } 145 | } 146 | } 147 | } -------------------------------------------------------------------------------- /tests/general/Manifest.Tests.ps1: -------------------------------------------------------------------------------- 1 | Describe "Validating the module manifest" { 2 | $moduleRoot = (Resolve-Path "$global:testroot\..\EntraAuth").Path 3 | $manifest = ((Get-Content "$moduleRoot\EntraAuth.psd1") -join "`n") | Invoke-Expression 4 | Context "Basic resources validation" { 5 | $files = Get-ChildItem "$moduleRoot\functions" -Recurse -File | Where-Object Name -like "*.ps1" 6 | It "Exports all functions in the public folder" -TestCases @{ files = $files; manifest = $manifest } { 7 | 8 | $functions = (Compare-Object -ReferenceObject $files.BaseName -DifferenceObject $manifest.FunctionsToExport | Where-Object SideIndicator -Like '<=').InputObject 9 | $functions | Should -BeNullOrEmpty 10 | } 11 | It "Exports no function that isn't also present in the public folder" -TestCases @{ files = $files; manifest = $manifest } { 12 | $functions = (Compare-Object -ReferenceObject $files.BaseName -DifferenceObject $manifest.FunctionsToExport | Where-Object SideIndicator -Like '=>').InputObject 13 | $functions | Should -BeNullOrEmpty 14 | } 15 | 16 | It "Exports none of its internal functions" -TestCases @{ moduleRoot = $moduleRoot; manifest = $manifest } { 17 | $files = Get-ChildItem "$moduleRoot\internal\functions" -Recurse -File -Filter "*.ps1" 18 | $files | Where-Object BaseName -In $manifest.FunctionsToExport | Should -BeNullOrEmpty 19 | } 20 | } 21 | 22 | Context "Individual file validation" { 23 | It "The root module file exists" -TestCases @{ moduleRoot = $moduleRoot; manifest = $manifest } { 24 | Test-Path "$moduleRoot\$($manifest.RootModule)" | Should -Be $true 25 | } 26 | 27 | foreach ($format in $manifest.FormatsToProcess) 28 | { 29 | It "The file $format should exist" -TestCases @{ moduleRoot = $moduleRoot; format = $format } { 30 | Test-Path "$moduleRoot\$format" | Should -Be $true 31 | } 32 | } 33 | 34 | foreach ($type in $manifest.TypesToProcess) 35 | { 36 | It "The file $type should exist" -TestCases @{ moduleRoot = $moduleRoot; type = $type } { 37 | Test-Path "$moduleRoot\$type" | Should -Be $true 38 | } 39 | } 40 | 41 | foreach ($assembly in $manifest.RequiredAssemblies) 42 | { 43 | if ($assembly -like "*.dll") { 44 | It "The file $assembly should exist" -TestCases @{ moduleRoot = $moduleRoot; assembly = $assembly } { 45 | Test-Path "$moduleRoot\$assembly" | Should -Be $true 46 | } 47 | } 48 | else { 49 | It "The file $assembly should load from the GAC" -TestCases @{ moduleRoot = $moduleRoot; assembly = $assembly } { 50 | { Add-Type -AssemblyName $assembly } | Should -Not -Throw 51 | } 52 | } 53 | } 54 | 55 | foreach ($tag in $manifest.PrivateData.PSData.Tags) 56 | { 57 | It "Tags should have no spaces in name" -TestCases @{ tag = $tag } { 58 | $tag -match " " | Should -Be $false 59 | } 60 | } 61 | } 62 | } -------------------------------------------------------------------------------- /tests/general/PSScriptAnalyzer.Tests.ps1: -------------------------------------------------------------------------------- 1 | [CmdletBinding()] 2 | Param ( 3 | [switch] 4 | $SkipTest, 5 | 6 | [string[]] 7 | $CommandPath = @("$global:testroot\..\EntraAuth\functions", "$global:testroot\..\EntraAuth\internal\functions") 8 | ) 9 | 10 | BeforeDiscovery { 11 | if ($SkipTest) { return } 12 | 13 | $global:__pester_data.ScriptAnalyzer = New-Object System.Collections.ArrayList 14 | 15 | # Create an array containing the path and basename of all files to test 16 | $commandFiles = $CommandPath | ForEach-Object { 17 | Get-ChildItem -Path $_ -Recurse | Where-Object Name -like "*.ps1" 18 | } | ForEach-Object { 19 | @{ 20 | BaseName = $_.BaseName 21 | FullName = $_.FullName 22 | } 23 | } 24 | 25 | # Create an array contain all rules 26 | $scriptAnalyzerRules = Get-ScriptAnalyzerRule | ForEach-Object { 27 | @{ 28 | RuleName = $_.RuleName 29 | } 30 | } 31 | } 32 | 33 | Describe 'Invoking PSScriptAnalyzer against commandbase' { 34 | 35 | Context "Analyzing " -ForEach $commandFiles { 36 | BeforeAll { 37 | $analysis = Invoke-ScriptAnalyzer -Path $FullName -ExcludeRule PSAvoidTrailingWhitespace, PSShouldProcess 38 | } 39 | 40 | It "Should pass " -Foreach $scriptAnalyzerRules { 41 | # Test if the rule is present and if so create a string containing more info which will be shown in the details of the test output. If it's empty the test is succesfull as there is no problem with this rule. 42 | $analysis | Where-Object RuleName -EQ $RuleName | Foreach-Object { 43 | # Create a string 44 | "$($_.Severity) at Line $($_.Line) Column $($_.Column) with '$($_.Extent)'" 45 | # Add the data (and supress the output) to the global variable for later use 46 | $null = $global:__pester_data.ScriptAnalyzer.Add($_) 47 | } | Should -BeNullOrEmpty 48 | } 49 | } 50 | } -------------------------------------------------------------------------------- /tests/pester.ps1: -------------------------------------------------------------------------------- 1 | param ( 2 | $TestGeneral = $true, 3 | 4 | $TestFunctions = $true, 5 | 6 | [ValidateSet('None', 'Normal', 'Detailed', 'Diagnostic')] 7 | [Alias('Show')] 8 | $Output = "None", 9 | 10 | $Include = "*", 11 | 12 | $Exclude = "" 13 | ) 14 | 15 | Write-Host "Starting Tests" 16 | 17 | Write-Host "Importing Module" 18 | 19 | $global:testroot = $PSScriptRoot 20 | $global:__pester_data = @{ } 21 | 22 | Remove-Module EntraAuth -ErrorAction Ignore 23 | Import-Module "$PSScriptRoot\..\EntraAuth\EntraAuth.psd1" 24 | Import-Module "$PSScriptRoot\..\EntraAuth\EntraAuth.psm1" -Force 25 | 26 | # Need to import explicitly so we can use the configuration class 27 | Import-Module Pester 28 | 29 | Write-Host "Creating test result folder" 30 | $null = New-Item -Path "$PSScriptRoot\.." -Name TestResults -ItemType Directory -Force 31 | 32 | $totalFailed = 0 33 | $totalRun = 0 34 | 35 | $testresults = @() 36 | $config = [PesterConfiguration]::Default 37 | $config.TestResult.Enabled = $true 38 | 39 | #region Run General Tests 40 | if ($TestGeneral) 41 | { 42 | Write-Host "Modules imported, proceeding with general tests" 43 | foreach ($file in (Get-ChildItem "$PSScriptRoot\general" | Where-Object Name -like "*.Tests.ps1")) 44 | { 45 | if ($file.Name -notlike $Include) { continue } 46 | if ($file.Name -like $Exclude) { continue } 47 | 48 | Write-Host " Executing $($file.Name)" 49 | $config.TestResult.OutputPath = Join-Path "$PSScriptRoot\..\TestResults" "TEST-$($file.BaseName).xml" 50 | $config.Run.Path = $file.FullName 51 | $config.Run.PassThru = $true 52 | $config.Output.Verbosity = $Output 53 | $results = Invoke-Pester -Configuration $config 54 | foreach ($result in $results) 55 | { 56 | $totalRun += $result.TotalCount 57 | $totalFailed += $result.FailedCount 58 | $result.Tests | Where-Object Result -ne 'Passed' | ForEach-Object { 59 | $testresults += [pscustomobject]@{ 60 | Block = $_.Block 61 | Name = "It $($_.Name)" 62 | Result = $_.Result 63 | Message = $_.ErrorRecord.DisplayErrorMessage 64 | } 65 | } 66 | } 67 | } 68 | } 69 | #endregion Run General Tests 70 | 71 | $global:__pester_data.ScriptAnalyzer | Out-Host 72 | 73 | #region Test Commands 74 | if ($TestFunctions) 75 | { 76 | Write-Host "Proceeding with individual tests" 77 | foreach ($file in (Get-ChildItem "$PSScriptRoot\functions" -Recurse -File | Where-Object Name -like "*Tests.ps1")) 78 | { 79 | if ($file.Name -notlike $Include) { continue } 80 | if ($file.Name -like $Exclude) { continue } 81 | 82 | Write-Host " Executing $($file.Name)" 83 | $config.TestResult.OutputPath = Join-Path "$PSScriptRoot\..\TestResults" "TEST-$($file.BaseName).xml" 84 | $config.Run.Path = $file.FullName 85 | $config.Run.PassThru = $true 86 | $config.Output.Verbosity = $Output 87 | $results = Invoke-Pester -Configuration $config 88 | foreach ($result in $results) 89 | { 90 | $totalRun += $result.TotalCount 91 | $totalFailed += $result.FailedCount 92 | $result.Tests | Where-Object Result -ne 'Passed' | ForEach-Object { 93 | $testresults += [pscustomobject]@{ 94 | Block = $_.Block 95 | Name = "It $($_.Name)" 96 | Result = $_.Result 97 | Message = $_.ErrorRecord.DisplayErrorMessage 98 | } 99 | } 100 | } 101 | } 102 | } 103 | #endregion Test Commands 104 | 105 | $testresults | Sort-Object Describe, Context, Name, Result, Message | Format-List 106 | 107 | if ($totalFailed -eq 0) { Write-Host "All $totalRun tests executed without a single failure!" } 108 | else { Write-Host "$totalFailed tests out of $totalRun tests failed!" } 109 | 110 | if ($totalFailed -gt 0) 111 | { 112 | throw "$totalFailed / $totalRun tests failed!" 113 | } -------------------------------------------------------------------------------- /tests/readme.md: -------------------------------------------------------------------------------- 1 | # Description 2 | 3 | This is the folder, where all the tests go. 4 | 5 | Those are subdivided in two categories: 6 | 7 | - General 8 | - Function 9 | 10 | ## General Tests 11 | 12 | General tests are function generic and test for general policies. 13 | 14 | These test scan answer questions such as: 15 | 16 | - Is my module following my style guides? 17 | - Does any of my scripts have a syntax error? 18 | - Do my scripts use commands I do not want them to use? 19 | - Do my commands follow best practices? 20 | - Do my commands have proper help? 21 | 22 | Basically, these allow a general module health check. 23 | 24 | These tests are already provided as part of the template. 25 | 26 | ## Function Tests 27 | 28 | A healthy module should provide unit and integration tests for the commands & components it ships. 29 | Only then can be guaranteed, that they will actually perform as promised. 30 | 31 | However, as each such test must be specific to the function it tests, there cannot be much in the way of templates. --------------------------------------------------------------------------------