Private/Get-GraphToken.ps1
|
function Get-GraphToken { <# .SYNOPSIS Retrieves access tokens for Microsoft Graph API and other Microsoft cloud services. .DESCRIPTION This function obtains OAuth2 access tokens using client credentials flow for various Microsoft cloud services including Microsoft Graph, Teams, Exchange, Partner Center, and Azure Resource Manager. It supports both legacy scope names and direct scope URIs for maximum flexibility. .PARAMETER TenantId The Azure AD Tenant ID to authenticate against. .PARAMETER ClientId The Application (Client) ID of the Azure AD app registration. .PARAMETER ClientSecret The client secret for the Azure AD app registration. .PARAMETER CertificateThumbprint The thumbprint of a certificate installed in the local certificate store (Cert:\CurrentUser\My or Cert:\LocalMachine\My) for certificate-based authentication. The certificate must have a private key and use an RSA key pair. Used to build a JWT bearer assertion for the OAuth2 client credentials flow. .PARAMETER Scope The target service scope. Supports predefined values (Graph, Teams, Exchange, Partner, Azure) or direct scope URIs (e.g., 'https://management.azure.com/.default'). .EXAMPLE $Token = Get-GraphToken -TenantId $TenantId -ClientId $ClientId -ClientSecret $ClientSecret -Scope Graph Retrieves a Microsoft Graph access token. .EXAMPLE $ArmToken = Get-GraphToken -TenantId $TenantId -ClientId $ClientId -ClientSecret $ClientSecret -Scope Azure Retrieves an Azure Resource Manager access token. .EXAMPLE $Token = Get-GraphToken -TenantId $TenantId -ClientId $ClientId -CertificateThumbprint $Thumbprint -Scope Graph Retrieves a Microsoft Graph access token using certificate authentication. .EXAMPLE $ArmToken = Get-GraphToken -TenantId $TenantId -ClientId $ClientId -CertificateThumbprint $Thumbprint -Scope Azure Retrieves an Azure Resource Manager access token using certificate authentication. .EXAMPLE $CustomToken = Get-GraphToken -TenantId $TenantId -ClientId $ClientId -ClientSecret $ClientSecret -Scope 'https://vault.azure.net/.default' Retrieves a token for Azure Key Vault using direct scope URI. .OUTPUTS System.Management.Automation.PSObject Returns a token information object with SecureAccessToken, AccessToken, TokenType, ExpiresIn, ExpiresAt, Scope, TenantId, ClientId, Header, and GetSecureHeader properties. .NOTES Author: Tom de Leeuw Website: https://systom.dev Module: TenantReports SUPPORTED SCOPES: - Graph: Microsoft Graph API (https://graph.microsoft.com/.default) - Teams: Microsoft Teams API (https://api.spaces.skype.com/.default) - Exchange: Exchange Online API (https://outlook.office365.com/.default) - Partner: Partner Center API (https://api.partnercenter.microsoft.com/.default) - Azure: Azure Resource Manager API (https://management.azure.com/.default) - Custom: Any valid scope URI .LINK https://systom.dev #> [CmdletBinding(DefaultParameterSetName = 'ClientCredentials')] [OutputType([System.Management.Automation.PSObject])] param ( [Parameter(Mandatory = $true, ParameterSetName = 'ClientCredentials')] [Parameter(Mandatory = $true, ParameterSetName = 'Certificate')] [Parameter(Mandatory = $false, ParameterSetName = 'ManagedIdentity')] [Parameter(Mandatory = $false, ParameterSetName = 'Interactive')] [ValidateNotNullOrEmpty()] [string]$TenantId, [Parameter(Mandatory = $true, ParameterSetName = 'ClientCredentials')] [Parameter(Mandatory = $true, ParameterSetName = 'Certificate')] [Parameter(Mandatory = $false, ParameterSetName = 'Interactive')] [Alias('ApplicationId')] [ValidatePattern('^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$')] [string]$ClientId, [Parameter(Mandatory = $true, ParameterSetName = 'ClientCredentials')] [Alias('ApplicationSecret')] [ValidateNotNullOrEmpty()] [SecureString]$ClientSecret, [Parameter(Mandatory = $true, ParameterSetName = 'Certificate')] [ValidateNotNullOrEmpty()] [string]$CertificateThumbprint, [Parameter(Mandatory = $true, ParameterSetName = 'ManagedIdentity')] [switch]$UseManagedIdentity, [Parameter(Mandatory = $true, ParameterSetName = 'Interactive')] [switch]$Interactive, [Parameter(Mandatory = $false)] [ValidateScript({ # Allow predefined scope names or valid URIs $PredefinedScopes = @('Graph', 'Teams', 'Exchange', 'Partner', 'Azure') if ($_ -in $PredefinedScopes) { return $true } # Validate URI format for custom scopes try { $Uri = [System.Uri]$_ return $Uri.IsAbsoluteUri -and $_.EndsWith('/.default') } catch { throw "Scope must be one of: $($PredefinedScopes -join ', ') or a valid URI ending with '/.default'" } })] [string]$Scope = 'Graph' ) begin { if (-not $script:TokenCache) { $script:TokenCache = @{} } $ScopeMapping = @{ 'Graph' = 'https://graph.microsoft.com/.default' 'Teams' = 'https://api.spaces.skype.com/.default' 'Exchange' = 'https://outlook.office365.com/.default' 'Partner' = 'https://api.partnercenter.microsoft.com/.default' 'Azure' = 'https://management.azure.com/.default' } } process { try { # Determine the actual scope URI $ScopeUri = if ($ScopeMapping.ContainsKey($Scope)) { $ScopeMapping[$Scope] } else { # Assume it's a direct URI $Scope } # Create cache key $CacheKey = if ($Interactive) { "$ScopeUri-Interactive" } elseif ($UseManagedIdentity) { "$ScopeUri-ManagedIdentity" } elseif ($CertificateThumbprint) { "$ScopeUri-$ClientId-$TenantId-$CertificateThumbprint" } else { "$ScopeUri-$ClientId-$TenantId" } # Refresh tokens 5 minutes before expiration to prevent mid-operation failures if ($script:TokenCache.ContainsKey($CacheKey)) { $CachedToken = $script:TokenCache[$CacheKey] if ($CachedToken.ExpiresAt -gt [DateTime]::Now.AddMinutes(5)) { Write-Verbose "Using cached token (expires: $($CachedToken.ExpiresAt))" return $CachedToken } Write-Verbose 'Cached token expired, removing' $script:TokenCache.Remove($CacheKey) } Write-Verbose "Retrieving $($Scope) access token for scope: $($ScopeUri)" if ($Interactive) { Write-Verbose 'Using Interactive authentication (delegated permissions)' # For interactive auth, we leverage the existing MgGraph connection # Return a token info object that signals interactive mode $MgContext = Get-MgContext -ErrorAction SilentlyContinue if (-not $MgContext) { $errorRecord = [System.Management.Automation.ErrorRecord]::new( [System.Exception]::new('Get-GraphToken failed: No active Microsoft Graph connection found. Use Connect-TntGraphSession -Interactive first.'), 'GetGraphTokenInteractiveNoConnectionError', [System.Management.Automation.ErrorCategory]::ConnectionError, $null ) $PSCmdlet.ThrowTerminatingError($errorRecord) } # For interactive mode, return a special token info object # that indicates to use Invoke-MgGraphRequest instead of REST with bearer tokens $TokenInfo = [PSCustomObject]@{ SecureAccessToken = $null TokenType = 'Interactive' ExpiresIn = 3600 ExpiresAt = [DateTime]::Now.AddHours(1) Scope = $ScopeUri ServiceType = $Scope TenantId = $MgContext.TenantId ClientId = $MgContext.ClientId IsExpired = $false CreatedAt = [DateTime]::Now IsInteractive = $true GetSecureHeader = $null } | Add-Member -MemberType ScriptMethod -Name 'IsTokenExpired' -Value { # For interactive, check if MgGraph context is still valid $ctx = Get-MgContext -ErrorAction SilentlyContinue return $null -eq $ctx } -PassThru | Add-Member -MemberType ScriptMethod -Name 'ClearToken' -Value { # No-op for interactive - Disconnect-MgGraph handles cleanup } -PassThru | Add-Member -MemberType ScriptProperty -Name 'AccessToken' -Value { # Return null - callers should use Invoke-MgGraphRequest return $null } -PassThru | Add-Member -MemberType ScriptProperty -Name 'Header' -Value { # Return null - callers should use Invoke-MgGraphRequest return $null } -PassThru # Cache and return $script:TokenCache[$CacheKey] = $TokenInfo Write-Verbose "Interactive token info cached with key: $CacheKey" return $TokenInfo } elseif ($UseManagedIdentity) { Write-Verbose 'Using Managed Identity for authentication' # Use Azure Managed Identity to obtain token try { # Get the resource URL from scope URI (remove /.default) $ResourceUrl = $ScopeUri -replace '/\.default$', '' Write-Verbose "Requesting Managed Identity token for resource: $ResourceUrl" # Call Az.Accounts cmdlet for Managed Identity token $AzToken = Get-AzAccessToken -ResourceUrl $ResourceUrl -ErrorAction Stop # Build token request response object to match client credentials format $TokenRequest = [PSCustomObject]@{ access_token = $AzToken.Token token_type = 'Bearer' expires_in = [int](($AzToken.ExpiresOn.UtcDateTime - [datetime]::UtcNow).TotalSeconds) } } catch { $errorRecord = [System.Management.Automation.ErrorRecord]::new( [System.Exception]::new("Get-GraphToken failed to obtain token using Managed Identity: $($_.Exception.Message)", $_.Exception), 'GetGraphTokenManagedIdentityError', [System.Management.Automation.ErrorCategory]::AuthenticationError, $null ) $PSCmdlet.ThrowTerminatingError($errorRecord) } } elseif ($CertificateThumbprint) { Write-Verbose 'Using Certificate authentication with JWT bearer assertion' # Normalize thumbprint: remove whitespace, uppercase $NormalizedThumbprint = ($CertificateThumbprint -replace '\s', '').ToUpperInvariant() # Find certificate in local stores $Certificate = $null foreach ($StoreLocation in @('CurrentUser', 'LocalMachine')) { $CertPath = "Cert:\$StoreLocation\My\$NormalizedThumbprint" $Certificate = Get-Item -Path $CertPath -ErrorAction SilentlyContinue if ($Certificate) { Write-Verbose "Found certificate in $StoreLocation store" break } } if (-not $Certificate) { $errorRecord = [System.Management.Automation.ErrorRecord]::new( [System.Exception]::new("Get-GraphToken failed: Certificate with thumbprint '$NormalizedThumbprint' not found in Cert:\CurrentUser\My or Cert:\LocalMachine\My."), 'GetGraphTokenCertificateNotFoundError', [System.Management.Automation.ErrorCategory]::ObjectNotFound, $NormalizedThumbprint ) $PSCmdlet.ThrowTerminatingError($errorRecord) } # Validate certificate has a private key if (-not $Certificate.HasPrivateKey) { $errorRecord = [System.Management.Automation.ErrorRecord]::new( [System.Exception]::new("Get-GraphToken failed: Certificate '$NormalizedThumbprint' does not have a private key. Import the certificate with the private key (.pfx) to use certificate authentication."), 'GetGraphTokenCertificateNoPrivateKeyError', [System.Management.Automation.ErrorCategory]::SecurityError, $Certificate ) $PSCmdlet.ThrowTerminatingError($errorRecord) } # Validate RSA key type $RsaPrivateKey = [System.Security.Cryptography.X509Certificates.RSACertificateExtensions]::GetRSAPrivateKey($Certificate) if (-not $RsaPrivateKey) { $errorRecord = [System.Management.Automation.ErrorRecord]::new( [System.Exception]::new("Get-GraphToken failed: Certificate '$NormalizedThumbprint' does not have an RSA private key. Only RSA certificates are supported for JWT bearer assertions."), 'GetGraphTokenCertificateNotRsaError', [System.Management.Automation.ErrorCategory]::InvalidType, $Certificate ) $PSCmdlet.ThrowTerminatingError($errorRecord) } $JwtAssertion = $null try { # Build JWT header $CertHash = $Certificate.GetCertHash() $X5t = [Convert]::ToBase64String($CertHash) -replace '\+', '-' -replace '/', '_' -replace '=' $JwtHeader = @{ alg = 'RS256' typ = 'JWT' x5t = $X5t } # Add x5t#S256 if SHA-256 hash is available $CertHashSha256 = $Certificate.GetCertHash([System.Security.Cryptography.HashAlgorithmName]::SHA256) if ($CertHashSha256) { $X5tS256 = [Convert]::ToBase64String($CertHashSha256) -replace '\+', '-' -replace '/', '_' -replace '=' $JwtHeader['x5t#S256'] = $X5tS256 } # Build JWT claims $TokenEndpoint = "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/token" $Now = [DateTimeOffset]::UtcNow $JwtClaims = @{ aud = $TokenEndpoint iss = $ClientId sub = $ClientId jti = [Guid]::NewGuid().ToString() nbf = $Now.ToUnixTimeSeconds() exp = $Now.AddMinutes(10).ToUnixTimeSeconds() } # Encode header and claims $EncodedHeader = [Convert]::ToBase64String( [System.Text.Encoding]::UTF8.GetBytes(($JwtHeader | ConvertTo-Json -Compress)) ) -replace '\+', '-' -replace '/', '_' -replace '=' $EncodedClaims = [Convert]::ToBase64String( [System.Text.Encoding]::UTF8.GetBytes(($JwtClaims | ConvertTo-Json -Compress)) ) -replace '\+', '-' -replace '/', '_' -replace '=' # Sign JWT with RSA-SHA256 $DataToSign = [System.Text.Encoding]::UTF8.GetBytes("$EncodedHeader.$EncodedClaims") $Signature = $RsaPrivateKey.SignData( $DataToSign, [System.Security.Cryptography.HashAlgorithmName]::SHA256, [System.Security.Cryptography.RSASignaturePadding]::Pkcs1 ) $EncodedSignature = [Convert]::ToBase64String($Signature) -replace '\+', '-' -replace '/', '_' -replace '=' $JwtAssertion = "$EncodedHeader.$EncodedClaims.$EncodedSignature" # POST to token endpoint with JWT bearer assertion $AuthBody = @{ client_id = $ClientId scope = $ScopeUri grant_type = 'client_credentials' client_assertion_type = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer' client_assertion = $JwtAssertion } $TokenUri = "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/token" try { $TokenRequest = Invoke-RestMethod -Method Post -Uri $TokenUri -Body $AuthBody -ContentType 'application/x-www-form-urlencoded' -ErrorAction Stop } catch { $errorRecord = [System.Management.Automation.ErrorRecord]::new( [System.Exception]::new("Get-GraphToken failed to obtain token using Certificate authentication: $($_.Exception.Message)", $_.Exception), 'GetGraphTokenCertificateError', [System.Management.Automation.ErrorCategory]::AuthenticationError, $null ) $PSCmdlet.ThrowTerminatingError($errorRecord) } } finally { # Clear JWT strings from memory $JwtAssertion = $null if ($AuthBody) { $AuthBody.Clear() } $EncodedHeader = $null $EncodedClaims = $null $EncodedSignature = $null $DataToSign = $null if ($RsaPrivateKey -is [System.IDisposable]) { $RsaPrivateKey.Dispose() } } } else { Write-Verbose 'Using Client Credentials for authentication' # Convert SecureString to plain text for OAuth request $bstrPtr = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($ClientSecret) try { $PlainClientSecret = [System.Runtime.InteropServices.Marshal]::PtrToStringAuto($bstrPtr) } finally { [System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstrPtr) } # Prepare authentication body for client credentials flow $AuthBody = @{ client_id = $ClientId client_secret = $PlainClientSecret scope = $ScopeUri grant_type = 'client_credentials' } # Use v2.0 endpoint for OAuth2 $TokenUri = "https://login.microsoftonline.com/$($TenantId)/oauth2/v2.0/token" # Request the token try { $TokenRequest = Invoke-RestMethod -Method Post -Uri $TokenUri -Body $AuthBody -ContentType 'application/x-www-form-urlencoded' -ErrorAction Stop } catch { $errorRecord = [System.Management.Automation.ErrorRecord]::new( [System.Exception]::new("Get-GraphToken failed to obtain token using Client Credentials: $($_.Exception.Message)", $_.Exception), 'GetGraphTokenClientCredentialsError', [System.Management.Automation.ErrorCategory]::AuthenticationError, $null ) $PSCmdlet.ThrowTerminatingError($errorRecord) } finally { # Clear plain text secret from memory $PlainClientSecret = $null if ($AuthBody) { $AuthBody.Clear() } } } # Convert to read-only SecureString to prevent modification after creation if ($TokenRequest.access_token) { $SecureAccessToken = ConvertTo-SecureString -String $TokenRequest.access_token -AsPlainText -Force $SecureAccessToken.MakeReadOnly() } # Store ONLY the secure token in script scope - never store plain text $script:AccessToken = $SecureAccessToken # Create secure authorization header function $script:GetSecureAuthHeader = { param([SecureString]$SecureToken) if ($null -eq $SecureToken) { throw 'Access token is null or has been cleared' } # Decrypt token only when needed and clear immediately $bstrPtr = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($SecureToken) try { $PlainToken = [System.Runtime.InteropServices.Marshal]::PtrToStringAuto($bstrPtr) return @{ Authorization = "Bearer $PlainToken" 'Content-Type' = 'application/json' } } finally { [System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstrPtr) $PlainToken = $null } } Write-Verbose "Successfully retrieved access token. Expiration date: $([DateTime]::Now.AddSeconds($TokenRequest.expires_in))" # Return secure token information $TokenInfo = [PSCustomObject]@{ SecureAccessToken = $SecureAccessToken TokenType = $TokenRequest.token_type ExpiresIn = $TokenRequest.expires_in ExpiresAt = [DateTime]::Now.AddSeconds($TokenRequest.expires_in) Scope = $ScopeUri ServiceType = $Scope TenantId = $TenantId ClientId = $ClientId IsExpired = $false CreatedAt = [DateTime]::Now GetSecureHeader = $script:GetSecureAuthHeader } | Add-Member -MemberType ScriptMethod -Name 'IsTokenExpired' -Value { # Method to check if token is expired return [DateTime]::Now -gt $this.ExpiresAt } -PassThru | Add-Member -MemberType ScriptMethod -Name 'ClearToken' -Value { # Method to securely clear the token $this.SecureAccessToken = $null } -PassThru | Add-Member -MemberType ScriptProperty -Name 'AccessToken' -Value { # Decrypt SecureAccessToken on demand (required by Connect-ExchangeOnline and Connect-IPPSSession) if ($null -eq $this.SecureAccessToken) { return $null } $bstrPtr = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($this.SecureAccessToken) try { return [System.Runtime.InteropServices.Marshal]::PtrToStringAuto($bstrPtr) } finally { [System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstrPtr) } } -PassThru | Add-Member -MemberType ScriptProperty -Name 'Header' -Value { # Backward-compatible property: return auth header for API calls if ($null -eq $this.SecureAccessToken) { return $null } $bstrPtr = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($this.SecureAccessToken) try { $token = [System.Runtime.InteropServices.Marshal]::PtrToStringAuto($bstrPtr) return @{ Authorization = "Bearer $token" 'Content-Type' = 'application/json' } } finally { [System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstrPtr) $token = $null } } -PassThru # Store in script scope for backward compatibility $script:TokenInfo = $TokenInfo # Cache token for future requests $script:TokenCache[$CacheKey] = $TokenInfo Write-Verbose "Token cached with key: $CacheKey" return $script:TokenInfo } catch { $ErrorDetails = $_.Exception.Message # Enhanced error handling for common authentication issues if ($_.Exception.Response) { try { $ErrorStream = $_.Exception.Response.GetResponseStream() $Reader = New-Object System.IO.StreamReader($ErrorStream) $ErrorBody = $Reader.ReadToEnd() | ConvertFrom-Json $ErrorDetails = "$($ErrorBody.error): $($ErrorBody.error_description)" } catch { $ErrorDetails = "HTTP $($_.Exception.Response.StatusCode.value__): $($_.Exception.Response.StatusDescription)" } } Write-Error "Failed to retrieve access token for tenant '$($TenantId)' and scope '$($ScopeUri)': $($ErrorDetails)" # Provide scope-specific troubleshooting guidance $TroubleshootingGuidance = @' Common causes for authentication failures: 1. Incorrect Client ID or Client Secret 2. App registration not found in the specified tenant 3. Required permissions not granted or admin consent not provided 4. Client secret has expired 5. Tenant ID format is incorrect SCOPE-SPECIFIC REQUIREMENTS: '@ if ($ScopeUri -eq 'https://management.azure.com/.default') { $TroubleshootingGuidance += @' For Azure Resource Manager scope: - App registration needs 'Azure Service Management' API permissions - Service principal requires RBAC role assignments on target resources - Use Azure PowerShell: Connect-AzAccount to verify access - Check role assignments: Get-AzRoleAssignment -ServicePrincipalName <ClientId> '@ } elseif ($ScopeUri -eq 'https://graph.microsoft.com/.default') { $TroubleshootingGuidance += @' For Microsoft Graph scope: - App registration needs Microsoft Graph application permissions - Admin consent must be granted for all permissions - Check permissions in Azure Portal: App registrations > API permissions '@ } if ($CertificateThumbprint) { $TroubleshootingGuidance += @" CERTIFICATE-SPECIFIC TROUBLESHOOTING: - Verify the certificate is installed: Get-ChildItem Cert:\CurrentUser\My\$($CertificateThumbprint) - Check LocalMachine store: Get-ChildItem Cert:\LocalMachine\My\$($CertificateThumbprint) - Ensure the certificate has a private key (.pfx import) - Verify the certificate is uploaded to the app registration in Azure Portal - Check certificate expiration: (Get-Item Cert:\CurrentUser\My\$($CertificateThumbprint)).NotAfter - Ensure the certificate uses RSA key pair (EC keys are not supported) "@ } $TroubleshootingGuidance += @" Verify your app registration at: https://portal.azure.com/#blade/Microsoft_AAD_RegisteredApps/ApplicationMenuBlade/Overview/appId/$($ClientId) "@ Write-Warning $TroubleshootingGuidance $errorRecord = [System.Management.Automation.ErrorRecord]::new( [System.Exception]::new("Get-GraphToken failed to retrieve access token for tenant '$TenantId' and scope '$ScopeUri': $ErrorDetails", $_.Exception), 'GetGraphTokenAuthenticationError', [System.Management.Automation.ErrorCategory]::AuthenticationError, $TenantId ) $PSCmdlet.ThrowTerminatingError($errorRecord) } } } |