public/Connect-MsalEssentials.ps1
|
<#
.SYNOPSIS Connects to Microsoft Entra ID and keeps the resulting token for this PowerShell session. .DESCRIPTION Signs in as the current user through the system's default web browser (delegated rights) by calling Get-MsalEssentialsDelegatedTokenSystemBrowser, then stores the result and the MSAL client in module scope so Get-MsalEssentialsToken can renew the token silently later. Nothing is written to disk. Running Connect-MsalEssentials again replaces the stored connection. Re-importing the module (Import-Module -Force) also clears it. Errors from the sign-in are passed through unchanged, including the FullyQualifiedErrorId values (MsalEssentials.Timeout, MsalEssentials.ConditionalAccess, ...) and the TargetObject details described in Get-MsalEssentialsDelegatedTokenSystemBrowser. This is a terminating error for the command, so use -ErrorAction 'Stop' or try/catch in scripts to make sure nothing runs after a failed sign-in. With -Certificate or -ClientSecret the app signs in as itself (application permissions, client credentials flow) by calling Get-MsalEssentialsApplicationToken. No user, browser or redirect URI is involved. The token carries the application permissions that were granted to the app and consented by an administrator. MSAL does not issue a refresh token for this flow: Get-MsalEssentialsToken renews the token by requesting a new one with the same certificate or secret. Currently supported: DelegatedSystemBrowser interactive sign-in as a user (default) AppCertificate application permissions with a certificate AppSecret application permissions with a client secret Planned parameter sets (not implemented yet): AccessToken, ManagedIdentity .PARAMETER TenantId The directory (tenant) ID or verified domain to authenticate against. .PARAMETER ClientId The application (client) ID registered in Entra ID. .PARAMETER Scopes The scopes to request. Defaults to Microsoft Graph's default scope set. The same scopes are used when the token is renewed. With -Certificate or -ClientSecret this must be exactly one scope that ends with '/.default', for example 'https://graph.microsoft.com/.default'. Application permissions are granted on the app registration, not requested per call. .PARAMETER TimeoutSeconds How long to wait for the interactive sign-in to complete. If omitted, the default of Get-MsalEssentialsDelegatedTokenSystemBrowser is used. .PARAMETER RedirectUri The redirect URI MSAL listens on. Defaults to 'http://localhost'. It must be registered on the app registration (Entra admin center > App registrations > your app > Authentication > Add a platform > Mobile and desktop applications). Entra ID ignores the port when it matches a localhost redirect URI, so registering 'http://localhost' also covers 'http://localhost:1234'. Do not register several localhost URIs that differ only by port. The path is matched and is case-sensitive: 'http://localhost/MyApp' must be registered exactly like that. Use different paths to tell several localhost registrations apart. The system browser flow needs a loopback address, because MSAL listens on it. MSAL's documentation only describes 'http://localhost' and 'http://localhost:<port>'. A path, '127.0.0.1' (recommended by Entra over 'localhost', but it can only be added through the app manifest) and 'https://localhost' are valid on the Entra side but are not described in MSAL's documentation, so test them first. '[::1]' is not supported by Entra ID. Addresses that are not loopback are rejected by MSAL with an "Only loopback redirect uri is supported" error and need a different sign-in flow, such as the embedded web view or WAM, which this module does not include. .PARAMETER LoginHintUpn A user principal name (UPN) to pre-fill the username/email field in the browser. It is only a hint, not a guarantee: the user can still change it or sign in with a different account. Useful for guest sign-ins, where it points to the right account in the target tenant, and to save users from typing their address. When a hint is given, the prompt behavior is set to NoPrompt, so Entra ID decides based on the hint and the browser cookies. If the browser already has a session for that account, sign-in can complete without asking for credentials. The hint is only shown as pre-filled when the browser has no signed-in session; otherwise Entra ID uses the session (or its account picker) and the hint does not appear. .PARAMETER Certificate A certificate with a private key (X509Certificate2) to sign in as the application. The public part (.cer) must be uploaded on the app registration (Entra admin center > App registrations > your app > Certificates & secrets > Certificates). Load it from the certificate store with Get-ChildItem -Path 'Cert:\CurrentUser\My' or from a PFX file with Get-PfxCertificate. The app needs application permissions with admin consent, and no redirect URI. .PARAMETER ClientSecret The client secret value of the app registration as a SecureString, to sign in as the application. Use the secret's Value (shown once when it is created), not its Secret ID. A certificate is the safer choice for production, because a secret is a shared password that can leak. Create the SecureString without leaving the secret in the console history, for example with Read-Host -AsSecureString or from a secret store (Get-Secret). ConvertTo-SecureString -AsPlainText puts the plain text into your script. .PARAMETER PassThru Returns the connection as an object with the properties AuthenticationType, TenantId, ClientId, Username, Scopes, ExpiresOn and AccessToken, plus a CreateAuthorizationHeader() method that returns 'Bearer <token>'. This is the same object Get-MsalEssentialsToken returns. The access token is a credential. It is hidden from the default console display, but Format-List *, ConvertTo-Json, Export-Clixml and similar commands will include it. Don't log or share the object. .EXAMPLE Connect-MsalEssentials -TenantId $tenantId -ClientId $clientId Opens the default browser for sign-in and stores the connection. .EXAMPLE Connect-MsalEssentials -TenantId $tenantId -ClientId $clientId -LoginHintUpn 'user@hitboo.test' Opens the default browser for sign-in with a pre-filled username and stores the connection. .EXAMPLE Connect-MsalEssentials -TenantId $tenantId -ClientId $clientId -Scopes 'User.Read' -TimeoutSeconds 60 -PassThru Requests only User.Read, gives up after 60 seconds, and returns the connection. .EXAMPLE Connect-MsalEssentials -TenantId $tenantId -ClientId $clientId -RedirectUri 'http://localhost:1234' Listens on the fixed port 1234. 'http://localhost:1234' must be registered on the app. .EXAMPLE $certificate = Get-ChildItem -Path 'Cert:\CurrentUser\My' | Where-Object -FilterScript { $_.Thumbprint -eq $thumbprint } Connect-MsalEssentials -TenantId $tenantId -ClientId $clientId -Certificate $certificate Signs in as the application with a certificate from the store (application permissions). .EXAMPLE $secret = Read-Host -Prompt 'Client secret' -AsSecureString Connect-MsalEssentials -TenantId $tenantId -ClientId $clientId -ClientSecret $secret -Scopes 'https://vault.azure.net/.default' Signs in as the application with a client secret and requests an Azure Key Vault token. .EXAMPLE try { $connection = Connect-MsalEssentials -PassThru -TenantId $tenantId -ClientId $clientId -ErrorAction 'Stop' $request = @{ Uri = 'https://graph.microsoft.com/beta/me' Method = 'GET' Headers = @{ Authorization = $connection.CreateAuthorizationHeader() } ContentType = 'application/json' } $result = Invoke-RestMethod @request } catch { Write-Warning -Message $_.ErrorDetails.Message } #> function Connect-MsalEssentials { [CmdletBinding(DefaultParameterSetName = 'DelegatedSystemBrowser')] [OutputType('MsalEssentials.Connection')] param ( # TenantId and ClientId have no parameter set, so they belong to every set. # When the managed identity set is added, move them into the sets that need them. [Parameter(Mandatory)] [string] $TenantId, [Parameter(Mandatory)] [string] $ClientId, # Keep this default in sync with the helper functions. # It is needed here because the scopes are stored for silent token renewal. # The app sets are validated in Get-MsalEssentialsApplicationToken. [Parameter(ParameterSetName = 'DelegatedSystemBrowser')] [Parameter(ParameterSetName = 'AppCertificate')] [Parameter(ParameterSetName = 'AppSecret')] [string[]] $Scopes = @('https://graph.microsoft.com/.default'), # No default and no validation here: these live in the helper function. [Parameter(ParameterSetName = 'DelegatedSystemBrowser')] [int] $TimeoutSeconds, [Parameter(ParameterSetName = 'DelegatedSystemBrowser')] [string] $RedirectUri, [Parameter(ParameterSetName = 'DelegatedSystemBrowser')] [string] $LoginHintUpn, [Parameter(Mandatory, ParameterSetName = 'AppCertificate')] [ValidateScript({ if (-not $_.HasPrivateKey) { throw 'The certificate has no private key. Load it with its private key, for example from the certificate store or from a PFX file.' } $true })] [System.Security.Cryptography.X509Certificates.X509Certificate2] $Certificate, [Parameter(Mandatory, ParameterSetName = 'AppSecret')] [System.Security.SecureString] $ClientSecret, [Parameter()] [switch] $PassThru ) $authenticationResult = $null $authenticationType = $null $clientApplication = $null # public (delegated) or confidential (application) MSAL client $requestedScopes = $null switch ($PSCmdlet.ParameterSetName) { 'DelegatedSystemBrowser' { $requestedScopes = [string[]]@($Scopes) $tokenParameters = @{ TenantId = $TenantId ClientId = $ClientId Scopes = $requestedScopes # Receives the MSAL client. Silent renewal needs the same client # instance, because the refresh token lives in its in-memory cache. ApplicationReference = [ref]$clientApplication } # Only pass optional parameters that were actually supplied, so # their defaults live in one place (the helper function). foreach ($optionalParameterName in 'TimeoutSeconds', 'RedirectUri', 'LoginHintUpn') { if ($PSBoundParameters.ContainsKey($optionalParameterName)) { $tokenParameters[$optionalParameterName] = $PSBoundParameters[$optionalParameterName] } } $authenticationType = 'Delegated' # The helper throws a statement-terminating error. Without this # try/catch, execution would continue after a failed sign-in and # store an empty connection. try { $authenticationResult = Get-MsalEssentialsDelegatedTokenSystemBrowser @tokenParameters } catch { $PSCmdlet.ThrowTerminatingError($_) } } { $_ -in 'AppCertificate', 'AppSecret' } { $requestedScopes = [string[]]@($Scopes) $tokenParameters = @{ TenantId = $TenantId ClientId = $ClientId Scopes = $requestedScopes # Receives the MSAL client. Renewal asks it for a new token, and # it keeps the certificate or secret. ApplicationReference = [ref]$clientApplication } if ($PSCmdlet.ParameterSetName -eq 'AppCertificate') { $tokenParameters['Certificate'] = $Certificate } else { $tokenParameters['ClientSecret'] = $ClientSecret } $authenticationType = 'Application' try { $authenticationResult = Get-MsalEssentialsApplicationToken @tokenParameters } catch { $PSCmdlet.ThrowTerminatingError($_) } } # Future parameter sets go here, each setting $authenticationResult, # $authenticationType and $requestedScopes the same way: # 'AccessToken' -> use an access token that was obtained elsewhere # 'ManagedIdentity' -> managed identity endpoint } if (-not $authenticationResult -or [string]::IsNullOrEmpty($authenticationResult.AccessToken)) { $noTokenRecord = [System.Management.Automation.ErrorRecord]::new( [System.InvalidOperationException]::new('Sign-in completed but no access token was returned.'), 'MsalEssentials.NoToken', [System.Management.Automation.ErrorCategory]::AuthenticationError, $null ) $PSCmdlet.ThrowTerminatingError($noTokenRecord) } # Module-scope state. Only the values that cannot be derived are stored; # username, scopes and expiry are read from AuthenticationResult, so they # stay correct after a silent renewal replaces it. The raw # AuthenticationResult is never returned from this function (see the note # in the helper about printing the full AuthenticationResult). $script:MsalEssentialsSession = @{ AuthenticationType = $authenticationType TenantId = $TenantId # as supplied, reused if a new sign-in is needed ClientId = $ClientId RequestedScopes = $requestedScopes ClientApplication = $clientApplication # the type follows AuthenticationType AuthenticationResult = $authenticationResult } $username = if ($authenticationResult.Account) { $authenticationResult.Account.Username } else { $null } Write-Verbose -Message ( 'Connected as {0} ({1}), token expires {2}.' -f $(if ($username) { $username } else { 'application' }), $authenticationType, $authenticationResult.ExpiresOn ) if ($PassThru) { ConvertTo-MsalEssentialsConnection -Session $script:MsalEssentialsSession } } |