Providers/Entra/Public/Connect-EntraEnvironment.ps1
|
function Connect-EntraEnvironment { <# .SYNOPSIS Establishes the certificate-authenticated Graph connection every other function uses .DESCRIPTION Authenticates app-only against Microsoft Graph using a certificate, and stores the resulting connection in module scope for the rest of the session. Certificate rather than client secret is the only supported option, and that is a deliberate constraint rather than an omission. A secret has to exist somewhere in plaintext for the caller to pass it in, which puts it in a script, a variable, or a transcript. A certificate's private key stays in the store, only a short-lived signed assertion crosses the wire, and nothing this module handles is worth stealing. Three things are settled here and then relied on everywhere else, because getting any of them wrong after objects exist is expensive: - The prefix. Every object this module creates carries it, and teardown will not touch an object without it. Changing the prefix between a seed and a teardown orphans everything the first run made. - The UPN suffix. Seed users are created on it, and it is the fallback proof of ownership if the seed tag is ever lost. It defaults to the tenant's onmicrosoft.com routing domain, which cannot receive external mail, so a seeded mailbox cannot reach a real recipient. - The tenant. Read back from Graph and reported, rather than echoed from the parameter, so a connection to the wrong directory is visible immediately instead of at the moment something is created in it. The credential is validated before it is stored. A connection object that exists but does not work is worse than no connection, because the failure then surfaces at the first seeding call rather than at the point the credential was supplied. .PARAMETER TenantId Directory (tenant) ID, or a verified domain name .PARAMETER ClientId Application (client) ID of the app registration to authenticate as .PARAMETER CertificateThumbprint SHA-1 thumbprint of a certificate in CurrentUser\My or LocalMachine\My .PARAMETER CertificatePath Path to a PFX file holding the certificate and its private key .PARAMETER CertificatePassword Password protecting the PFX file .PARAMETER Certificate An already-loaded certificate object .PARAMETER Prefix Namespace applied to every object created, and required for teardown to consider one of them ours. Must end in a separator so seeded names cannot run into real ones. .PARAMETER UpnSuffix Domain seeded users are created on. Defaults to the tenant's onmicrosoft.com routing domain, which is deliberate: it accepts no external mail. .PARAMETER Interactive Sign a person in by device code instead of authenticating as an application. The token is theirs and is held in memory for the session. On its own this asks for nothing beyond what the client has already been consented for, which is enough to bootstrap the service app and is deliberately no more than that. .PARAMETER FullAccess With -Interactive: ask for the delegated form of every permission the service app would be granted, so the session can seed and tear down as the signed-in person and no application registration ever has to exist. The first sign-in shows one consent screen for exactly that list. A Global Administrator's role does not by itself make the token able to call the APIs; the scopes do, and this is what asks for them. .PARAMETER BootstrapClientId The public client the device-code sign-in goes through. Defaults to Microsoft Graph Command Line Tools, which is present in every tenant. .PARAMETER Scope An explicit list of delegated scopes to ask for instead of either default. Takes precedence over -FullAccess. .PARAMETER GraphBaseUri Graph endpoint. Change only for a sovereign cloud. .PARAMETER PassThru Returns the connection summary .OUTPUTS EntraConnection when -PassThru is supplied .EXAMPLE PS> Connect-EntraEnvironment -TenantId b818de68-9112-40b3-adc2-6838048cd611 ` -ClientId 895bcc2f-ad2f-4ec4-8dfe-310251b5e1a8 ` -CertificateThumbprint 9FC871A73BCE94325FD5E05DA33CBFF651E0A467 DESCRIPTION: Connects using a certificate already in the personal store OUTPUT: Nothing, unless -PassThru is supplied USE CASE: The normal path, run once at the start of a session .EXAMPLE PS> Connect-EntraEnvironment -TenantId $tenant -ClientId $app ` -CertificatePath .\lab.pfx -CertificatePassword $pfxPassword -PassThru DESCRIPTION: Connects using a PFX rather than the certificate store OUTPUT: The connection summary, including the tenant name read back from Graph USE CASE: A build agent that receives the certificate as a file .EXAMPLE PS> Connect-EntraEnvironment -TenantId $tenant -Interactive -FullAccess PS> New-EntraEnvironment DESCRIPTION: Signs a Global Administrator in and seeds as them, with no service app OUTPUT: A device code to enter, one consent screen on the first run, then the seed USE CASE: A tenant whose owner does not want an application registration left behind .NOTES Author: Jeffrey Stuhr Blog: https://www.techbyjeff.net LinkedIn: https://www.linkedin.com/in/jeffrey-stuhr-034214aa/ #> [CmdletBinding(DefaultParameterSetName = 'Thumbprint')] [OutputType('EntraConnection')] [Diagnostics.CodeAnalysis.SuppressMessageAttribute('PSReviewUnusedParameter', 'UseSecretStore', Justification = 'Selects the Stored parameter set rather than being read in the body; the switch on ParameterSetName is what acts on it.')] param( [Parameter(Mandatory = $true)] [ValidateNotNullOrEmpty()] [string]$TenantId, [Parameter(Mandatory = $true, ParameterSetName = 'Thumbprint')] [Parameter(Mandatory = $true, ParameterSetName = 'Path')] [Parameter(Mandatory = $true, ParameterSetName = 'Object')] [ValidateNotNullOrEmpty()] [string]$ClientId, [Parameter(Mandatory = $true, ParameterSetName = 'Interactive')] [switch]$Interactive, [Parameter(ParameterSetName = 'Interactive')] [ValidateNotNullOrEmpty()] [string]$BootstrapClientId = '14d82eec-204b-4c2f-b7e8-296a70dab67e', [Parameter(ParameterSetName = 'Interactive')] [switch]$FullAccess, [Parameter(ParameterSetName = 'Interactive')] [ValidateNotNullOrEmpty()] [string[]]$Scope, [Parameter(Mandatory = $true, ParameterSetName = 'Stored')] [switch]$UseSecretStore, [Parameter(ParameterSetName = 'Stored')] [System.Security.SecureString]$VaultPassword, [Parameter(Mandatory = $true, ParameterSetName = 'Thumbprint')] [ValidateNotNullOrEmpty()] [string]$CertificateThumbprint, [Parameter(Mandatory = $true, ParameterSetName = 'Path')] [ValidateNotNullOrEmpty()] [string]$CertificatePath, [Parameter(ParameterSetName = 'Path')] [securestring]$CertificatePassword, [Parameter(Mandatory = $true, ParameterSetName = 'Object')] [System.Security.Cryptography.X509Certificates.X509Certificate2]$Certificate, [Parameter()] # Internal hyphens are admitted now, because the shared default carries one. Still ends # in a separator so a seeded name cannot run into a real one. [ValidatePattern('^[A-Za-z0-9][A-Za-z0-9_-]*[-_]$')] [string]$Prefix = $script:TestEnvironmentDefaultPrefix, [Parameter()] [ValidateNotNullOrEmpty()] [string]$UpnSuffix, [Parameter()] [ValidateNotNullOrEmpty()] [string]$GraphBaseUri = 'https://graph.microsoft.com', [Parameter()] [switch]$PassThru ) $resolvedCertificate = $null $resolvedClientId = $ClientId switch ($PSCmdlet.ParameterSetName) { 'Thumbprint' { $resolvedCertificate = Get-TestCertificate -Thumbprint $CertificateThumbprint } 'Path' { $resolvedCertificate = Get-TestCertificate -Path $CertificatePath -Password $CertificatePassword } 'Object' { $resolvedCertificate = Get-TestCertificate -Certificate $Certificate } 'Stored' { # The record is the authority on both which application to be and where its key # lives, so neither has to be typed. That is the whole point of having bootstrapped. $stored = Get-EntraStoredCredential -TenantId $TenantId -VaultPassword $VaultPassword $resolvedCertificate = $stored.Certificate $resolvedClientId = $stored.ClientId Write-Verbose "Using the stored credential for '$($stored.DisplayName)' (key in $($stored.KeyProtection))" } } # Built as a candidate and only promoted to $script:EntraConnection once it has proven it # can reach the tenant. Storing first would leave a broken connection behind on failure, # and the next command would report something unrelated. $candidate = @{ TenantId = $TenantId ClientId = $resolvedClientId Certificate = $resolvedCertificate AuthMode = 'Certificate' RefreshToken = $null GraphBaseUri = $GraphBaseUri.TrimEnd('/') Prefix = $Prefix UpnSuffix = $UpnSuffix AccessToken = $null TokenExpiresOn = $null TokenRoles = @() } if ($Interactive) { # The delegated path. The token is the signed-in human's, not an application's, and it # is held in memory only. By default it asks for .default - whatever the client is # already consented for - which is enough to bootstrap the service app and asks the # person to consent to nothing new. -FullAccess asks instead for the delegated form of # everything the service app is granted, for the person who would rather not leave an # application registration behind and will seed and tear down as themselves. $candidate.AuthMode = 'DeviceCode' $candidate.ClientId = $BootstrapClientId if (-not $Scope) { $Scope = if ($FullAccess) { Get-EntraDelegatedScope } else { @('.default') } } $session = New-EntraDeviceCodeToken -TenantId $TenantId -ClientId $BootstrapClientId ` -GraphBaseUri $candidate.GraphBaseUri -Scope $Scope $candidate.AccessToken = $session.AccessToken $candidate.RefreshToken = $session.RefreshToken $candidate.TokenExpiresOn = $session.ExpiresOn $candidate.TokenRoles = @(Get-EntraTokenRole -AccessToken $session.AccessToken) } else { Write-Verbose "Authenticating as $resolvedClientId against tenant $TenantId" $null = Get-EntraAccessToken -Connection $candidate } # Read the organisation back rather than trusting the parameter. A tenant id that is # merely well-formed authenticates against the wrong directory perfectly happily. $organization = $null try { $organization = (Invoke-EntraRequest -Method GET -Path '/organization' -Connection $candidate).value | Select-Object -First 1 } catch { throw (New-Object System.Exception( "Authenticated as $ClientId but could not read /organization. The app needs at least " + "Organization.Read.All or Directory.Read.All: $($_.Exception.Message)", $_.Exception)) } if (-not $organization) { Write-Error "Authenticated to tenant $TenantId but /organization returned nothing to identify it by." -ErrorAction Stop return } # The id from Graph, not the parameter. A domain name is a legal -TenantId and the rest of # the module builds URLs from this, so it has to be the resolved GUID either way. $candidate.TenantId = $organization.id $candidate.TenantName = $organization.displayName $verifiedDomains = @($organization.verifiedDomains) $candidate.VerifiedDomains = @($verifiedDomains | ForEach-Object { $_.name }) if (-not $candidate.UpnSuffix) { # The routing domain, identified by isInitial rather than by matching on # '.onmicrosoft.com'. A tenant can hold several onmicrosoft.com domains and only one # of them is the initial one; picking by suffix match gets that wrong on exactly the # tenants where it matters. $initial = $verifiedDomains | Where-Object { $_.isInitial } | Select-Object -First 1 if (-not $initial) { Write-Error ("No initial (onmicrosoft.com) domain found in tenant $($organization.displayName). " + "Pass -UpnSuffix explicitly to say which domain seeded users belong on.") -ErrorAction Stop return } $candidate.UpnSuffix = $initial.name Write-Verbose "Defaulted the seed UPN suffix to the routing domain $($initial.name)" } elseif ($candidate.VerifiedDomains -notcontains $candidate.UpnSuffix) { Write-Error ("$($candidate.UpnSuffix) is not a verified domain in tenant $($organization.displayName). " + "Graph rejects a user whose UPN suffix the tenant does not own. Verified: " + ($candidate.VerifiedDomains -join ', ')) -ErrorAction Stop return } $script:EntraConnection = $candidate Write-Verbose "Connected to $($candidate.TenantName) as $ClientId, seeding under $Prefix on $($candidate.UpnSuffix)" if ($Interactive) { # The person now holds a bootstrap token and needs to know whether to bootstrap. Told # here, while the token still carries their authority, rather than left to discover it # from New-TestServiceApp refusing to replace an app they did not know existed. Never # allowed to fail the connect: the connection is already established and correct. try { Write-EntraBootstrapNextStep -State (Get-EntraBootstrapState -Connection $candidate) -FullAccess:$FullAccess } catch { Write-Verbose "Could not work out the bootstrap state: $($_.Exception.Message)" } } if ($PassThru) { return [PSCustomObject]@{ PSTypeName = 'EntraConnection' TenantId = $candidate.TenantId TenantName = $candidate.TenantName ClientId = $candidate.ClientId AuthMode = $candidate.AuthMode CertificateThumbprint = $resolvedCertificate.Thumbprint KeyProtection = $(if ($stored) { $stored.KeyProtection } else { "None" }) CertificateExpires = $resolvedCertificate.NotAfter Prefix = $candidate.Prefix UpnSuffix = $candidate.UpnSuffix VerifiedDomains = $candidate.VerifiedDomains GrantedRoles = $candidate.TokenRoles } } } |