mirror of
https://github.com/Micke-K/IntuneManagement.git
synced 2026-09-28 10:55:38 +02:00
950 lines
49 KiB
PowerShell
950 lines
49 KiB
PowerShell
#ImportOrder 27
|
|
|
|
# Pure-PowerShell implementation of AuthenticationProvider.
|
|
#
|
|
# No MSAL.NET DLL, no Microsoft.Graph.Authentication SDK. The class talks to
|
|
# https://login.microsoftonline.com/{tenant}/oauth2/v2.0/token directly via
|
|
# Invoke-RestMethod. Designed for automation: scheduled tasks, CI runners,
|
|
# AKS workload identity, Azure VMs, App Service / Functions.
|
|
#
|
|
# Supported flows (dispatched by Connect() based on which arg is set, in
|
|
# this priority order):
|
|
#
|
|
# Token BYO bearer (no flow)
|
|
# DeviceCode device code grant (RFC 8628) — v2
|
|
# ManagedIdentity (no Federated*) IMDS / App Service / Functions
|
|
# FederatedTokenFile / FederatedToken Workload Identity Federation
|
|
# Certificate / CertificatePath client_credentials w/ private_key_jwt
|
|
# Secret client_credentials w/ shared secret
|
|
# Credential (PSCredential) ROPC (grant_type=password)
|
|
#
|
|
# All endpoint heavy lifting (HTTP, JWT signing, IMDS detection, error
|
|
# surfacing) lives in Internal/AuthenticationOAuthHelpers.ps1 — module
|
|
# functions are late-bound and tolerate types that don't resolve at parse
|
|
# time, which keeps this class file portable.
|
|
#
|
|
# Per-tenant cloud memory is stamped via Save-TenantCloud at the end of every
|
|
# successful Connect, matching what AuthenticationMSAL and AuthenticationMgGraph
|
|
# do. Same for AppEvents (AuthenticatedNewToken / AuthenticationTokenRefresh /
|
|
# AuthenticationUserDisconnected / AuthenticationFailed).
|
|
|
|
class AuthenticationOAuth : AuthenticationProvider {
|
|
|
|
# Token cache. Static so every reference to the provider sees the same
|
|
# store within a session. Keys are integer TokenIds (consistent with the
|
|
# other providers).
|
|
static [hashtable]$Tokens = @{}
|
|
static [int]$NextTokenId = 1
|
|
|
|
# Cached tenant display name per TenantId so GetUserInfo doesn't hit
|
|
# /organization on every refresh event. Failed lookups (e.g. app-only token
|
|
# without Organization.Read.All) cache $null so they aren't retried either.
|
|
static [hashtable]$TenantNameCache = @{}
|
|
|
|
AuthenticationOAuth() {
|
|
$this.Id = "OAuth"
|
|
$this.DisplayName = "Direct OAuth (no SDK)"
|
|
|
|
# Required contract.
|
|
$this.SupportsInteractive = $true # device code flow (RFC 8628)
|
|
$this.SupportsClientSecret = $true
|
|
$this.SupportsCertificate = $true
|
|
|
|
# Recommended.
|
|
$this.SupportsIdentityProvider = $true # IMDS + workload federation
|
|
$this.SupportsBYOToken = $true
|
|
$this.SupportsClaimsChallenge = $true # re-mints via the /token body (see GetClaimsToken)
|
|
|
|
$this.SupportsMultiTenant = $true
|
|
$this.SupportsRefresh = $true
|
|
$this.SupportsForget = $true
|
|
$this.SupportsCachedUsers = $false # No persistent on-disk cache
|
|
}
|
|
|
|
[void] Initialize() {
|
|
# Register the OAuth settings section (browser-login config). Guard on the
|
|
# settings API being loaded (module-load order); Initialize() runs from
|
|
# Register-AuthProvider, before the Settings dialog renders.
|
|
if(-not (Get-Command -Name Add-SettingsSection -ErrorAction SilentlyContinue)) { return }
|
|
if(-not (Get-Command -Name Add-SettingsObject -ErrorAction SilentlyContinue)) { return }
|
|
try {
|
|
# Order 9 = directly under the MSAL section (8). App (client) id and tenant id
|
|
# are NOT registered here - MSAL and OAuth are two ways of authenticating with
|
|
# the SAME app, so both resolve it from the common Entra settings in the
|
|
# Authentication section (Get-EntraApp: dropdown -> custom app id -> default
|
|
# Microsoft Graph PowerShell public client).
|
|
Add-SettingsSection -Title "OAuth" -Id "OAuth" -Order 9
|
|
|
|
Add-SettingsObject -Title "OAuth browser prompt" -Key "OAuthPrompt" -Type "List" -DefaultValue "select_account" `
|
|
-ItemsSource @(
|
|
[PSCustomObject]@{ Name = "Select account"; Value = "select_account" },
|
|
[PSCustomObject]@{ Name = "Force login"; Value = "login" },
|
|
[PSCustomObject]@{ Name = "Consent"; Value = "consent" },
|
|
[PSCustomObject]@{ Name = "None (silent)"; Value = "none" }
|
|
) `
|
|
-Description "OAuth /authorize prompt behaviour. 'Force login' re-authenticates even with an active browser session (equivalent to force-interactive); 'None' fails if interaction would be required." `
|
|
-Section "OAuth"
|
|
|
|
Add-SettingsObject -Title "OAuth login hint (UPN)" -Key "OAuthLoginHint" -Type "String" -DefaultValue "" `
|
|
-Description "Optional UPN to pre-fill on the sign-in page (login_hint)." `
|
|
-Section "OAuth"
|
|
|
|
Add-SettingsObject -Title "OAuth redirect port" -Key "OAuthRedirectPort" -Type "Int" -DefaultValue 0 `
|
|
-Description "Fixed loopback port for the browser redirect (http://localhost:<port>). 0 = pick a free port automatically. Set a fixed port only if your app registration requires a specific http://localhost:<port> redirect." `
|
|
-Section "OAuth"
|
|
|
|
Add-SettingsObject -Title "Remember login (cache token)" -Key "OAuthCacheToken" -Type "Boolean" -DefaultValue $false `
|
|
-Description "Persist the OAuth refresh token (DPAPI-encrypted, current user) so the app silently resumes the browser session after a restart. When off, you sign in again after each restart (usually a quick browser redirect via existing SSO)." `
|
|
-Section "OAuth"
|
|
}
|
|
catch {
|
|
Write-LogError "Failed to register OAuth settings section" $_.Exception
|
|
}
|
|
}
|
|
|
|
# Silent cross-restart resume for the browser flow. When "Remember login" is on and
|
|
# a DPAPI-cached refresh token exists, mint a fresh token via the refresh_token grant
|
|
# (no browser) and register it as the default. Returns $true on success. Base is a
|
|
# no-op; MSAL/other providers have their own resume paths.
|
|
[bool] TryResumeSession() {
|
|
try {
|
|
if((Get-SettingValue "OAuthCacheToken") -ne $true) { return $false }
|
|
$cache = Read-OAuthTokenCache
|
|
if(-not $cache -or -not $cache.RefreshToken) { return $false }
|
|
|
|
$cloudValue = if($cache.Cloud) { [string]$cache.Cloud } else { Get-DefaultCloud }
|
|
$authority = if($cache.Authority) { [string]$cache.Authority } else { (Get-CloudByValue $cloudValue).AADAuthority }
|
|
$resource = if($cache.Resource) { [string]$cache.Resource } else { "https://$((Get-CloudByValue $cloudValue).GraphHost)" }
|
|
$tenant = if($cache.TenantId) { [string]$cache.TenantId } else { 'organizations' }
|
|
$scope = "$resource/.default offline_access"
|
|
|
|
$tokenResp = Invoke-OAuthTokenRequest -Authority $authority -TenantId $tenant -Body @{
|
|
grant_type = 'refresh_token'
|
|
client_id = $cache.ClientId
|
|
refresh_token = $cache.RefreshToken
|
|
scope = $scope
|
|
}
|
|
if(-not $tokenResp -or -not $tokenResp.access_token) { return $false }
|
|
|
|
$expiresAt = [DateTime]::UtcNow.AddMinutes(50)
|
|
if($tokenResp.expires_in) { $expiresAt = [DateTime]::UtcNow.AddSeconds([int]$tokenResp.expires_in) }
|
|
|
|
# Recover the real tenant from the token when we resumed under 'organizations'.
|
|
$tenantId = [string]$cache.TenantId
|
|
try {
|
|
$jwt = Get-JWTtoken $tokenResp.access_token
|
|
if($jwt -and $jwt.Payload -and $jwt.Payload.tid) { $tenantId = [string]$jwt.Payload.tid }
|
|
} catch { }
|
|
|
|
$cred = [ordered]@{
|
|
AuthMethod = 'AuthCode'
|
|
Cloud = $cloudValue
|
|
Authority = $authority
|
|
Resource = $resource
|
|
TenantId = $tenantId
|
|
ClientId = $cache.ClientId
|
|
}
|
|
$tokenId = Get-NextAuthTokenId
|
|
$entry = [ordered]@{
|
|
Id = $tokenId
|
|
AccessToken = [string]$tokenResp.access_token
|
|
RefreshToken = if($tokenResp.refresh_token) { [string]$tokenResp.refresh_token } else { [string]$cache.RefreshToken }
|
|
ExpiresAt = $expiresAt
|
|
TenantId = $tenantId
|
|
ClientId = $cache.ClientId
|
|
AuthMethod = 'AuthCode'
|
|
Cloud = $cloudValue
|
|
Resource = $resource
|
|
CredentialState = $cred
|
|
AcquiredAt = [DateTime]::UtcNow
|
|
}
|
|
[AuthenticationOAuth]::Tokens[$tokenId] = $entry
|
|
|
|
# A rotated refresh token must overwrite the cached one.
|
|
if($tokenResp.refresh_token) {
|
|
[void](Save-OAuthTokenCache -Data @{
|
|
RefreshToken = [string]$tokenResp.refresh_token
|
|
ClientId = $cache.ClientId
|
|
TenantId = $tenantId
|
|
Cloud = $cloudValue
|
|
Authority = $authority
|
|
Resource = $resource
|
|
AuthMethod = 'AuthCode'
|
|
})
|
|
}
|
|
|
|
[void](Register-AuthToken -Provider $this -TokenId $tokenId -Cloud $cloudValue -Default)
|
|
Write-Log "OAuth provider: resumed cached browser session (TokenId=$tokenId, tenant=$tenantId)"
|
|
return $true
|
|
}
|
|
catch {
|
|
Write-LogError "OAuth provider: TryResumeSession failed" $_.Exception
|
|
return $false
|
|
}
|
|
}
|
|
|
|
Hidden [string] ResolvePublicClientId([string]$AppId) {
|
|
if(-not [String]::IsNullOrWhiteSpace($AppId)) { return $AppId }
|
|
|
|
# Match MSAL's app selection: Settings -> Entra app dropdown, then custom
|
|
# app id, then the default Microsoft Graph PowerShell public client.
|
|
if(Get-Command Get-EntraApp -ErrorAction SilentlyContinue) {
|
|
try {
|
|
$entraApp = Get-EntraApp
|
|
if($entraApp -and -not [String]::IsNullOrWhiteSpace([string]$entraApp.ClientId)) {
|
|
return [string]$entraApp.ClientId
|
|
}
|
|
} catch { }
|
|
}
|
|
|
|
return "14d82eec-204b-4c2f-b7e8-296a70dab67e"
|
|
}
|
|
|
|
Hidden [string] ResolveTenantId([string]$TenantId) {
|
|
if(-not [String]::IsNullOrWhiteSpace($TenantId)) { return $TenantId }
|
|
|
|
# Shared identity config: the common Entra settings supply the tenant the same
|
|
# way they supply the app id. Get-EntraApp surfaces EntraCustomTenantId on
|
|
# custom-app rows; the built-in app rows have no TenantId property, which
|
|
# safely yields $null here.
|
|
if(Get-Command Get-EntraApp -ErrorAction SilentlyContinue) {
|
|
try {
|
|
$entraApp = Get-EntraApp
|
|
if($entraApp -and -not [String]::IsNullOrWhiteSpace([string]$entraApp.TenantId)) {
|
|
return [string]$entraApp.TenantId
|
|
}
|
|
} catch { }
|
|
}
|
|
|
|
# 'organizations' = any work/school account; the real tenant id is recovered
|
|
# from the token's tid claim after sign-in.
|
|
return 'organizations'
|
|
}
|
|
|
|
# === Auth lifecycle ===
|
|
|
|
[PSCustomObject] Connect([hashtable]$Arguments) {
|
|
Write-Log "AuthenticationOAuth.Connect starting"
|
|
|
|
$cloudValue = if($Arguments.Cloud) { [string]$Arguments.Cloud } else { Get-DefaultCloud }
|
|
$cloudEntry = Get-CloudByValue $cloudValue
|
|
$authority = $cloudEntry.AADAuthority
|
|
$graphHost = $cloudEntry.GraphHost
|
|
$defaultScope = "https://$graphHost/.default"
|
|
$resource = "https://$graphHost"
|
|
|
|
$tenantId = [string]$Arguments.TenantId
|
|
$clientId = [string]$Arguments.AppId
|
|
|
|
# Determine flow + run it. State stored in $cred is what Refresh() and
|
|
# GetAccessToken() use to re-acquire when the access token expires.
|
|
$cred = $null
|
|
$tokenResp = $null
|
|
$authMethod = $null
|
|
|
|
try {
|
|
if($Arguments.ContainsKey('Token') -and $Arguments.Token) {
|
|
$authMethod = 'BYO'
|
|
$cred = [ordered]@{
|
|
AuthMethod = $authMethod
|
|
Cloud = $cloudValue
|
|
Authority = $authority
|
|
Resource = $resource
|
|
TenantId = $tenantId
|
|
ClientId = $clientId
|
|
}
|
|
$tokenResp = [PSCustomObject]@{
|
|
access_token = [string]$Arguments.Token
|
|
expires_in = $null # unknown; parsed from JWT exp below
|
|
token_type = 'Bearer'
|
|
}
|
|
}
|
|
elseif( ($Arguments.ContainsKey('Browser') -and $Arguments.Browser) -or
|
|
($Arguments.ContainsKey('Interactive') -and $Arguments.Interactive -and
|
|
-not ($Arguments.ContainsKey('DeviceCode') -and $Arguments.DeviceCode) -and
|
|
((Get-CacheObject "ShowUI") -eq $true)) ) {
|
|
# Browser (Authorization Code + PKCE, loopback redirect). Chosen for an
|
|
# explicit -Browser, or for -Interactive when a GUI is present (ShowUI);
|
|
# headless -Interactive keeps going to device code below.
|
|
$authMethod = 'AuthCode'
|
|
# Client id / tenant: explicit args win, else the common Entra settings
|
|
# (same app selection as MSAL - dropdown, custom app id, then the
|
|
# well-known Microsoft Graph PowerShell public client, which already
|
|
# has http://localhost registered).
|
|
$clientId = $this.ResolvePublicClientId($clientId)
|
|
$acTenant = $this.ResolveTenantId($tenantId)
|
|
$prompt = if($Arguments.Prompt) { [string]$Arguments.Prompt } else { [string](Get-SettingValue "OAuthPrompt") }
|
|
$loginHint = if($Arguments.LoginHint) { [string]$Arguments.LoginHint } else { [string](Get-SettingValue "OAuthLoginHint") }
|
|
$redirectPort = if($Arguments.RedirectPort) { [int]$Arguments.RedirectPort } else { [int](Get-SettingValue "OAuthRedirectPort") }
|
|
$timeoutSec = 600
|
|
try { $t = [int](Get-SettingValue "MSGraphInteractiveTimeoutSec"); if($t -gt 0) { $timeoutSec = $t } } catch { }
|
|
|
|
$cred = [ordered]@{
|
|
AuthMethod = $authMethod
|
|
Cloud = $cloudValue
|
|
Authority = $authority
|
|
Resource = $resource
|
|
TenantId = $tenantId
|
|
ClientId = $clientId
|
|
}
|
|
try {
|
|
$tokenResp = Invoke-OAuthAuthCodeFlow -Authority $authority -TenantId $acTenant -ClientId $clientId `
|
|
-Scope "$defaultScope offline_access" -RedirectPort $redirectPort -Prompt $prompt -LoginHint $loginHint -TimeoutSec $timeoutSec
|
|
}
|
|
catch [System.OperationCanceledException] {
|
|
# The user clicked Cancel on the sign-in overlay. That is a decision,
|
|
# not a broken browser, so do not start a second (device code) login
|
|
# behind their back - let it out and leave the session signed out.
|
|
Write-Log "OAuth provider: browser sign-in cancelled by the user" 2
|
|
throw
|
|
}
|
|
catch {
|
|
# Browser unavailable (headless -Browser, no default browser, or the
|
|
# loopback port is blocked). Fall back to device code so sign-in can
|
|
# still complete. Refresh works identically for both (refresh_token).
|
|
Write-Log "OAuth provider: browser sign-in failed ($($_.Exception.Message)); falling back to device code" 2
|
|
$tokenResp = Invoke-OAuthDeviceCodeFlow -Authority $authority -TenantId $acTenant -ClientId $clientId -Scope "$defaultScope offline_access"
|
|
}
|
|
}
|
|
elseif(($Arguments.ContainsKey('DeviceCode') -and $Arguments.DeviceCode) -or
|
|
($Arguments.ContainsKey('Interactive') -and $Arguments.Interactive)) {
|
|
if($Arguments.Interactive -and -not $Arguments.DeviceCode) {
|
|
# -Interactive reached here means no GUI was available for the
|
|
# browser flow above (headless), so use RFC 8628 device code -
|
|
# keeping the Connect-IntuneManagement -Interactive story working
|
|
# on every provider.
|
|
Write-Log "OAuth provider: -Interactive routed to device code flow (no GUI for browser login)"
|
|
}
|
|
$authMethod = 'DeviceCode'
|
|
$clientId = $this.ResolvePublicClientId($clientId)
|
|
if(-not $clientId) { throw "AppId is required for device code auth" }
|
|
# Tenant from the common Entra settings when not explicit (same
|
|
# resolution as the browser flow above).
|
|
$dcTenant = $this.ResolveTenantId($tenantId)
|
|
$cred = [ordered]@{
|
|
AuthMethod = $authMethod
|
|
Cloud = $cloudValue
|
|
Authority = $authority
|
|
Resource = $resource
|
|
TenantId = $tenantId
|
|
ClientId = $clientId
|
|
}
|
|
# offline_access so the response includes a refresh_token —
|
|
# that's what AcquireFromState uses for silent renewal.
|
|
$tokenResp = Invoke-OAuthDeviceCodeFlow -Authority $authority -TenantId $dcTenant -ClientId $clientId -Scope "$defaultScope offline_access"
|
|
}
|
|
elseif($Arguments.ContainsKey('ManagedIdentity') -and $Arguments.ManagedIdentity -and
|
|
-not ($Arguments.FederatedTokenFile -or $Arguments.FederatedToken)) {
|
|
$authMethod = 'IMDS'
|
|
if(-not $clientId -and $Arguments.ManagedIdentityClientId) {
|
|
$clientId = [string]$Arguments.ManagedIdentityClientId
|
|
}
|
|
$cred = [ordered]@{
|
|
AuthMethod = $authMethod
|
|
Cloud = $cloudValue
|
|
Authority = $authority
|
|
Resource = $resource
|
|
TenantId = $tenantId
|
|
ClientId = $clientId
|
|
}
|
|
$tokenResp = Invoke-OAuthIMDS -Resource $resource -ClientId $clientId
|
|
# IMDS responds with token_type / access_token / expires_in (string seconds);
|
|
# tenant_id is also included. Use the IMDS-reported tenant if our caller
|
|
# didn't supply one.
|
|
if(-not $tenantId -and $tokenResp.tenant_id) {
|
|
$tenantId = $tokenResp.tenant_id
|
|
$cred.TenantId = $tenantId
|
|
}
|
|
}
|
|
elseif($Arguments.FederatedTokenFile -or $Arguments.FederatedToken) {
|
|
$authMethod = 'Federated'
|
|
if(-not $tenantId) { throw "TenantId is required for federated credential auth" }
|
|
if(-not $clientId) { throw "AppId is required for federated credential auth" }
|
|
$cred = [ordered]@{
|
|
AuthMethod = $authMethod
|
|
Cloud = $cloudValue
|
|
Authority = $authority
|
|
Resource = $resource
|
|
TenantId = $tenantId
|
|
ClientId = $clientId
|
|
FederatedTokenFile = [string]$Arguments.FederatedTokenFile
|
|
FederatedToken = [string]$Arguments.FederatedToken
|
|
}
|
|
$assertion = Get-OAuthFederatedAssertion -FederatedToken $cred.FederatedToken -FederatedTokenFile $cred.FederatedTokenFile
|
|
$tokenResp = Invoke-OAuthTokenRequest -Authority $authority -TenantId $tenantId -Body @{
|
|
grant_type = 'client_credentials'
|
|
client_id = $clientId
|
|
scope = $defaultScope
|
|
client_assertion_type = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer'
|
|
client_assertion = $assertion
|
|
}
|
|
}
|
|
elseif($Arguments.ContainsKey('Certificate') -and $Arguments.Certificate) {
|
|
$authMethod = 'Certificate'
|
|
if(-not $tenantId) { throw "TenantId is required for certificate auth" }
|
|
if(-not $clientId) { throw "AppId is required for certificate auth" }
|
|
$cert = $this.ResolveCertificate($Arguments.Certificate, $null, $null)
|
|
$cred = [ordered]@{
|
|
AuthMethod = $authMethod
|
|
Cloud = $cloudValue
|
|
Authority = $authority
|
|
Resource = $resource
|
|
TenantId = $tenantId
|
|
ClientId = $clientId
|
|
Certificate = $cert
|
|
}
|
|
$assertion = Get-OAuthClientAssertion -Certificate $cert -ClientId $clientId -Authority $authority -TenantId $tenantId
|
|
$tokenResp = Invoke-OAuthTokenRequest -Authority $authority -TenantId $tenantId -Body @{
|
|
grant_type = 'client_credentials'
|
|
client_id = $clientId
|
|
scope = $defaultScope
|
|
client_assertion_type = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer'
|
|
client_assertion = $assertion
|
|
}
|
|
}
|
|
elseif($Arguments.ContainsKey('CertificatePath') -and $Arguments.CertificatePath) {
|
|
$authMethod = 'Certificate'
|
|
if(-not $tenantId) { throw "TenantId is required for certificate auth" }
|
|
if(-not $clientId) { throw "AppId is required for certificate auth" }
|
|
$cert = $this.ResolveCertificate($null, [string]$Arguments.CertificatePath, $Arguments.CertificatePassword)
|
|
$cred = [ordered]@{
|
|
AuthMethod = $authMethod
|
|
Cloud = $cloudValue
|
|
Authority = $authority
|
|
Resource = $resource
|
|
TenantId = $tenantId
|
|
ClientId = $clientId
|
|
Certificate = $cert
|
|
CertificatePath = [string]$Arguments.CertificatePath
|
|
CertificatePassword = $Arguments.CertificatePassword
|
|
}
|
|
$assertion = Get-OAuthClientAssertion -Certificate $cert -ClientId $clientId -Authority $authority -TenantId $tenantId
|
|
$tokenResp = Invoke-OAuthTokenRequest -Authority $authority -TenantId $tenantId -Body @{
|
|
grant_type = 'client_credentials'
|
|
client_id = $clientId
|
|
scope = $defaultScope
|
|
client_assertion_type = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer'
|
|
client_assertion = $assertion
|
|
}
|
|
}
|
|
elseif($Arguments.ContainsKey('Secret') -and $Arguments.Secret) {
|
|
$authMethod = 'ClientSecret'
|
|
if(-not $tenantId) { throw "TenantId is required for client-secret auth" }
|
|
if(-not $clientId) { throw "AppId is required for client-secret auth" }
|
|
$secretPlain = if($Arguments.Secret -is [SecureString]) {
|
|
[System.Net.NetworkCredential]::new("", $Arguments.Secret).Password
|
|
} else {
|
|
[string]$Arguments.Secret
|
|
}
|
|
$cred = [ordered]@{
|
|
AuthMethod = $authMethod
|
|
Cloud = $cloudValue
|
|
Authority = $authority
|
|
Resource = $resource
|
|
TenantId = $tenantId
|
|
ClientId = $clientId
|
|
Secret = if($Arguments.Secret -is [SecureString]) { $Arguments.Secret } else { ConvertTo-SecureString $secretPlain -AsPlainText -Force }
|
|
}
|
|
$tokenResp = Invoke-OAuthTokenRequest -Authority $authority -TenantId $tenantId -Body @{
|
|
grant_type = 'client_credentials'
|
|
client_id = $clientId
|
|
client_secret = $secretPlain
|
|
scope = $defaultScope
|
|
}
|
|
}
|
|
elseif($Arguments.ContainsKey('Credential') -and $Arguments.Credential) {
|
|
$authMethod = 'Password'
|
|
if(-not $tenantId) { throw "TenantId is required for password auth" }
|
|
if(-not $clientId) { throw "AppId is required for password auth" }
|
|
$pscred = [PSCredential]$Arguments.Credential
|
|
$passwordPlain = $pscred.GetNetworkCredential().Password
|
|
$cred = [ordered]@{
|
|
AuthMethod = $authMethod
|
|
Cloud = $cloudValue
|
|
Authority = $authority
|
|
Resource = $resource
|
|
TenantId = $tenantId
|
|
ClientId = $clientId
|
|
Username = $pscred.UserName
|
|
Password = $pscred.Password
|
|
}
|
|
$tokenResp = Invoke-OAuthTokenRequest -Authority $authority -TenantId $tenantId -Body @{
|
|
grant_type = 'password'
|
|
client_id = $clientId
|
|
username = $pscred.UserName
|
|
password = $passwordPlain
|
|
scope = "$defaultScope offline_access"
|
|
}
|
|
}
|
|
else {
|
|
throw "AuthenticationOAuth.Connect: no supported credential argument was provided. Pass -Secret, -Certificate, -CertificatePath, -ManagedIdentity, -FederatedTokenFile/-FederatedToken, -Credential, -DeviceCode, or -Token."
|
|
}
|
|
}
|
|
catch [System.OperationCanceledException] {
|
|
# An interactive user explicitly abandoned the flow. Do not publish the
|
|
# canonical AuthenticationFailed event: consumers use that event for real
|
|
# authentication faults (and may tear down/redraw an existing session).
|
|
Write-Log "OAuth provider Connect cancelled by the user (method: $authMethod)" 2
|
|
return $null
|
|
}
|
|
catch {
|
|
Write-LogError "OAuth provider Connect failed (method: $authMethod)" $_.Exception
|
|
Invoke-AuthTokenFailed -Provider $this.Id -TenantId $tenantId -Message "OAuth Connect failed (method: $authMethod)" -Exception $_.Exception
|
|
return $null
|
|
}
|
|
|
|
if(-not $tokenResp -or -not $tokenResp.access_token) {
|
|
Write-Log "OAuth provider: token request returned no access_token (method: $authMethod)" 3
|
|
Invoke-AuthTokenFailed -Provider $this.Id -TenantId $tenantId -Message "OAuth token request returned no access_token (method: $authMethod)"
|
|
return $null
|
|
}
|
|
|
|
# Compute expiry. Prefer expires_in (relative seconds; reliable across all
|
|
# flows). Fall back to JWT exp claim if expires_in is absent (BYO token).
|
|
$expiresAt = [DateTime]::UtcNow.AddMinutes(50)
|
|
if($tokenResp.expires_in) {
|
|
$secs = [int]$tokenResp.expires_in
|
|
$expiresAt = [DateTime]::UtcNow.AddSeconds($secs)
|
|
}
|
|
else {
|
|
try {
|
|
$jwt = Get-JWTtoken $tokenResp.access_token
|
|
if($jwt -and $jwt.Payload -and $jwt.Payload.exp) {
|
|
$expiresAt = [DateTimeOffset]::FromUnixTimeSeconds([long]$jwt.Payload.exp).UtcDateTime
|
|
}
|
|
} catch { }
|
|
}
|
|
|
|
# If TenantId wasn't supplied (BYO token, IMDS without tenant_id),
|
|
# extract it from the access_token's `tid` claim so multi-tenant
|
|
# routing (Get-GraphDomain, Save-TenantCloud) still works.
|
|
if(-not $tenantId) {
|
|
try {
|
|
$jwt = Get-JWTtoken $tokenResp.access_token
|
|
if($jwt -and $jwt.Payload -and $jwt.Payload.tid) {
|
|
$tenantId = [string]$jwt.Payload.tid
|
|
$cred.TenantId = $tenantId
|
|
}
|
|
} catch { }
|
|
}
|
|
|
|
# Allocate a TokenId from the central registry so ids are globally unique
|
|
# across providers (MSAL/OAuth/MgGraph), not just within this provider.
|
|
$tokenId = Get-NextAuthTokenId
|
|
$entry = [ordered]@{
|
|
Id = $tokenId
|
|
AccessToken = [string]$tokenResp.access_token
|
|
RefreshToken = if($tokenResp.refresh_token) { [string]$tokenResp.refresh_token } else { $null }
|
|
ExpiresAt = $expiresAt
|
|
TenantId = $tenantId
|
|
ClientId = $clientId
|
|
AuthMethod = $authMethod
|
|
Cloud = $cloudValue
|
|
Resource = $resource
|
|
CredentialState = $cred
|
|
AcquiredAt = [DateTime]::UtcNow
|
|
}
|
|
[AuthenticationOAuth]::Tokens[$tokenId] = $entry
|
|
|
|
# Opt-in cross-restart persistence for the browser flow: DPAPI-encrypt the
|
|
# refresh token when "Remember login" (OAuthCacheToken) is on. Only for the
|
|
# interactive browser method - service/BYO flows re-acquire from their own
|
|
# credentials and shouldn't leave a refresh token on disk.
|
|
if($authMethod -eq 'AuthCode' -and $entry.RefreshToken -and ((Get-SettingValue "OAuthCacheToken") -eq $true)) {
|
|
[void](Save-OAuthTokenCache -Data @{
|
|
RefreshToken = $entry.RefreshToken
|
|
ClientId = $clientId
|
|
TenantId = $tenantId
|
|
Cloud = $cloudValue
|
|
Authority = $authority
|
|
Resource = $resource
|
|
AuthMethod = $authMethod
|
|
})
|
|
}
|
|
|
|
Write-Log "OAuth provider: acquired token (TokenId=$tokenId, method=$authMethod, tenant=$tenantId, expires=$($expiresAt.ToLocalTime().ToString('s')))"
|
|
|
|
# Persist per-tenant cloud memory so subsequent calls land on the same authority.
|
|
try {
|
|
if($tenantId) {
|
|
Save-TenantCloud -TenantId $tenantId -Cloud $cloudValue
|
|
Save-SettingStoreValue "" "LastLoggedOnCloud" $cloudValue
|
|
}
|
|
} catch {
|
|
Write-LogDebug "OAuth provider: Save-TenantCloud failed: $($_.Exception.Message)"
|
|
}
|
|
|
|
# Realign the active provider so subsequent Invoke-MSGraphAPI calls route here.
|
|
try {
|
|
$cur = Get-AuthProvider
|
|
if($cur -and $cur.Id -ne $this.Id) {
|
|
Write-Log "Active auth provider auto-switched from '$($cur.Id)' to '$($this.Id)' because OAuth authentication succeeded"
|
|
Set-ActiveAuthProvider -Id $this.Id
|
|
}
|
|
} catch { }
|
|
|
|
# Register with the central token registry, which fires AuthenticatedNewToken
|
|
# with the canonical [IMAuthToken] payload (no longer fired directly here).
|
|
$token = Register-AuthToken -Provider $this -TokenId $tokenId -Cloud $cloudValue
|
|
return $token
|
|
}
|
|
|
|
[bool] Disconnect([int]$TokenId) {
|
|
if(-not [AuthenticationOAuth]::Tokens.ContainsKey($TokenId)) { return $false }
|
|
$entry = [AuthenticationOAuth]::Tokens[$TokenId]
|
|
Write-Log "OAuth provider: disconnected TokenId=$TokenId (tenant=$($entry.TenantId))"
|
|
# Unregister BEFORE dropping the backend entry so the disconnect snapshot can
|
|
# still hydrate via GetUserInfo. The registry fires AuthenticationUserDisconnected
|
|
# (and promotes a survivor default if needed).
|
|
Unregister-AuthToken -TokenId $TokenId
|
|
[AuthenticationOAuth]::Tokens.Remove($TokenId) | Out-Null
|
|
# Sign-out of a browser session also drops the persisted refresh token so a
|
|
# later launch doesn't silently resume the account the user just signed out of.
|
|
if($entry.AuthMethod -eq 'AuthCode') { Clear-OAuthTokenCache }
|
|
return $true
|
|
}
|
|
|
|
[bool] Refresh([int]$TokenId) {
|
|
if(-not [AuthenticationOAuth]::Tokens.ContainsKey($TokenId)) { return $false }
|
|
try {
|
|
$newToken = $this.AcquireFromState($TokenId)
|
|
return [bool]$newToken
|
|
}
|
|
catch {
|
|
Write-LogError "OAuth provider: refresh failed for TokenId=$TokenId" $_.Exception
|
|
return $false
|
|
}
|
|
}
|
|
|
|
# Re-run whatever flow originally minted this entry, replacing the access
|
|
# token (and refresh_token, where applicable) in place. Fires
|
|
# AuthenticationTokenRefresh on success. Used both by the Refresh() public
|
|
# method and by GetAccessToken's preflight expiry check.
|
|
Hidden [string] AcquireFromState([int]$TokenId) {
|
|
return $this.AcquireFromState($TokenId, $null)
|
|
}
|
|
|
|
# $Claims carries a CAE claims challenge (from a Graph 401) into the /token
|
|
# request so the re-minted token satisfies the tenant's policy change.
|
|
Hidden [string] AcquireFromState([int]$TokenId, [string]$Claims) {
|
|
if(-not [AuthenticationOAuth]::Tokens.ContainsKey($TokenId)) { return $null }
|
|
$entry = [AuthenticationOAuth]::Tokens[$TokenId]
|
|
$cred = $entry.CredentialState
|
|
$defaultScope = "$($cred.Resource)/.default"
|
|
|
|
$tokenResp = $null
|
|
switch($cred.AuthMethod) {
|
|
'BYO' {
|
|
# BYO bearer can't be silently refreshed — caller must Connect again.
|
|
Write-Log "OAuth provider: BYO token (TokenId=$TokenId) cannot be refreshed automatically" 2
|
|
return $null
|
|
}
|
|
'IMDS' {
|
|
if($Claims) {
|
|
# IMDS / App Service token endpoints don't accept a claims
|
|
# parameter — a CAE challenge can't be satisfied here.
|
|
Write-Log "OAuth provider: managed identity tokens cannot satisfy a CAE claims challenge (TokenId=$TokenId)" 2
|
|
return $null
|
|
}
|
|
$tokenResp = Invoke-OAuthIMDS -Resource $cred.Resource -ClientId $cred.ClientId
|
|
}
|
|
'Federated' {
|
|
$assertion = Get-OAuthFederatedAssertion -FederatedToken $cred.FederatedToken -FederatedTokenFile $cred.FederatedTokenFile
|
|
$tokenResp = Invoke-OAuthTokenRequest -Authority $cred.Authority -TenantId $cred.TenantId -Claims $Claims -Body @{
|
|
grant_type = 'client_credentials'
|
|
client_id = $cred.ClientId
|
|
scope = $defaultScope
|
|
client_assertion_type = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer'
|
|
client_assertion = $assertion
|
|
}
|
|
}
|
|
'Certificate' {
|
|
$assertion = Get-OAuthClientAssertion -Certificate $cred.Certificate -ClientId $cred.ClientId -Authority $cred.Authority -TenantId $cred.TenantId
|
|
$tokenResp = Invoke-OAuthTokenRequest -Authority $cred.Authority -TenantId $cred.TenantId -Claims $Claims -Body @{
|
|
grant_type = 'client_credentials'
|
|
client_id = $cred.ClientId
|
|
scope = $defaultScope
|
|
client_assertion_type = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer'
|
|
client_assertion = $assertion
|
|
}
|
|
}
|
|
'ClientSecret' {
|
|
$secretPlain = [System.Net.NetworkCredential]::new("", $cred.Secret).Password
|
|
$tokenResp = Invoke-OAuthTokenRequest -Authority $cred.Authority -TenantId $cred.TenantId -Claims $Claims -Body @{
|
|
grant_type = 'client_credentials'
|
|
client_id = $cred.ClientId
|
|
client_secret = $secretPlain
|
|
scope = $defaultScope
|
|
}
|
|
}
|
|
'DeviceCode' {
|
|
# Silent-only here: never re-prompt with a new device code from a
|
|
# refresh path. No refresh token → the 401/expiry surfaces and the
|
|
# user re-runs Connect with -DeviceCode explicitly.
|
|
if(-not $entry.RefreshToken) {
|
|
Write-Log "OAuth provider: device-code token (TokenId=$TokenId) has no refresh token - run Connect-IntuneManagement -DeviceCode again" 2
|
|
return $null
|
|
}
|
|
$dcTenant = if($cred.TenantId) { $cred.TenantId } else { 'organizations' }
|
|
$tokenResp = Invoke-OAuthTokenRequest -Authority $cred.Authority -TenantId $dcTenant -Claims $Claims -Body @{
|
|
grant_type = 'refresh_token'
|
|
client_id = $cred.ClientId
|
|
refresh_token = $entry.RefreshToken
|
|
scope = "$defaultScope offline_access"
|
|
}
|
|
}
|
|
'AuthCode' {
|
|
# Browser (auth-code) refresh is silent - the refresh_token grant,
|
|
# identical to DeviceCode. No browser/redirect needed. Missing refresh
|
|
# token means the user must sign in again.
|
|
if(-not $entry.RefreshToken) {
|
|
Write-Log "OAuth provider: browser token (TokenId=$TokenId) has no refresh token - sign in again" 2
|
|
return $null
|
|
}
|
|
$acTenant = if($cred.TenantId) { $cred.TenantId } else { 'organizations' }
|
|
$tokenResp = Invoke-OAuthTokenRequest -Authority $cred.Authority -TenantId $acTenant -Claims $Claims -Body @{
|
|
grant_type = 'refresh_token'
|
|
client_id = $cred.ClientId
|
|
refresh_token = $entry.RefreshToken
|
|
scope = "$defaultScope offline_access"
|
|
}
|
|
}
|
|
'Password' {
|
|
# Prefer refresh_token grant if we got one; falls back to ROPC re-prompt.
|
|
if($entry.RefreshToken) {
|
|
$tokenResp = Invoke-OAuthTokenRequest -Authority $cred.Authority -TenantId $cred.TenantId -Claims $Claims -Body @{
|
|
grant_type = 'refresh_token'
|
|
client_id = $cred.ClientId
|
|
refresh_token = $entry.RefreshToken
|
|
scope = "$defaultScope offline_access"
|
|
}
|
|
}
|
|
else {
|
|
$passwordPlain = [System.Net.NetworkCredential]::new("", $cred.Password).Password
|
|
$tokenResp = Invoke-OAuthTokenRequest -Authority $cred.Authority -TenantId $cred.TenantId -Claims $Claims -Body @{
|
|
grant_type = 'password'
|
|
client_id = $cred.ClientId
|
|
username = $cred.Username
|
|
password = $passwordPlain
|
|
scope = "$defaultScope offline_access"
|
|
}
|
|
}
|
|
}
|
|
default {
|
|
throw "OAuth provider: unknown AuthMethod '$($cred.AuthMethod)' on TokenId=$TokenId"
|
|
}
|
|
}
|
|
|
|
if(-not $tokenResp -or -not $tokenResp.access_token) {
|
|
Write-Log "OAuth provider: refresh request returned no access_token for TokenId=$TokenId" 3
|
|
return $null
|
|
}
|
|
|
|
$expiresAt = [DateTime]::UtcNow.AddMinutes(50)
|
|
if($tokenResp.expires_in) {
|
|
$expiresAt = [DateTime]::UtcNow.AddSeconds([int]$tokenResp.expires_in)
|
|
}
|
|
$entry.AccessToken = [string]$tokenResp.access_token
|
|
if($tokenResp.refresh_token) { $entry.RefreshToken = [string]$tokenResp.refresh_token }
|
|
$entry.ExpiresAt = $expiresAt
|
|
$entry.AcquiredAt = [DateTime]::UtcNow
|
|
[AuthenticationOAuth]::Tokens[$TokenId] = $entry
|
|
|
|
Write-LogDebug "OAuth provider: refreshed TokenId=$TokenId (method=$($cred.AuthMethod), expires=$($expiresAt.ToLocalTime().ToString('s')))"
|
|
|
|
# Re-persist the rotated refresh token for the browser flow when caching is on.
|
|
if($cred.AuthMethod -eq 'AuthCode' -and $entry.RefreshToken -and ((Get-SettingValue "OAuthCacheToken") -eq $true)) {
|
|
[void](Save-OAuthTokenCache -Data @{
|
|
RefreshToken = $entry.RefreshToken
|
|
ClientId = $cred.ClientId
|
|
TenantId = $entry.TenantId
|
|
Cloud = $entry.Cloud
|
|
Authority = $cred.Authority
|
|
Resource = $cred.Resource
|
|
AuthMethod = 'AuthCode'
|
|
})
|
|
}
|
|
|
|
Update-AuthToken -TokenId $TokenId
|
|
return $entry.AccessToken
|
|
}
|
|
|
|
# CAE entry point — Invoke-MSGraphAPI calls this when Graph answers 401 with
|
|
# a WWW-Authenticate claims challenge. Re-mints the token with the challenge
|
|
# attached; returns the new access token, or $null when the flow can't
|
|
# satisfy claims (BYO, managed identity, no refresh token).
|
|
[string] AcquireWithClaims([int]$TokenId, [string]$ClaimsChallenge) {
|
|
return $this.AcquireFromState($TokenId, $ClaimsChallenge)
|
|
}
|
|
|
|
# Satisfy a CAE claims challenge by re-running the credential flow with the claims
|
|
# attached to the /token body. Returns $null when the flow can't satisfy the claims
|
|
# (e.g. BYO / managed identity). OAuth has no interactive browser escalation, so
|
|
# $AllowInteractive is not used.
|
|
[string] GetClaimsToken([int]$TokenId, [string]$Resource, [string]$ClaimsChallenge, [bool]$AllowInteractive) {
|
|
return $this.AcquireWithClaims($TokenId, $ClaimsChallenge)
|
|
}
|
|
|
|
# === Token retrieval ===
|
|
|
|
[string] GetAccessToken([int]$TokenId, [string]$Resource) {
|
|
if($TokenId -le 0) {
|
|
# Caller didn't pin a TokenId; pick the most recently-acquired entry.
|
|
if([AuthenticationOAuth]::Tokens.Count -eq 0) { return $null }
|
|
$latest = [AuthenticationOAuth]::Tokens.Values | Sort-Object AcquiredAt -Descending | Select-Object -First 1
|
|
$TokenId = $latest.Id
|
|
}
|
|
if(-not [AuthenticationOAuth]::Tokens.ContainsKey($TokenId)) { return $null }
|
|
$entry = [AuthenticationOAuth]::Tokens[$TokenId]
|
|
|
|
# 5-minute slack so we don't hand out a token that expires mid-request.
|
|
if($entry.ExpiresAt -le [DateTime]::UtcNow.AddMinutes(5)) {
|
|
$refreshed = $this.AcquireFromState($TokenId)
|
|
if($refreshed) { return $refreshed }
|
|
# Refresh failed; surface the stale token rather than $null and let
|
|
# Invoke-MSGraphAPI handle the inevitable 401 — same behavior as MSAL.
|
|
}
|
|
return [string]$entry.AccessToken
|
|
}
|
|
|
|
[datetime] GetAccessTokenExpiry([int]$TokenId, [string]$Resource) {
|
|
if(-not [AuthenticationOAuth]::Tokens.ContainsKey($TokenId)) {
|
|
return [datetime]::MaxValue
|
|
}
|
|
# ExpiresAt is stored UTC; return LOCAL to match the provider contract (MSAL
|
|
# returns ExpiresOn.LocalDateTime) and the local-time comparisons in
|
|
# Invoke-MSGraphAPI / Test-DefaultTokenExpired. Returning raw UTC made callers
|
|
# in ahead-of-UTC timezones see a just-issued token as already expired.
|
|
return [AuthenticationOAuth]::Tokens[$TokenId].ExpiresAt.ToLocalTime()
|
|
}
|
|
|
|
# === Display / picker data ===
|
|
|
|
[PSCustomObject] GetUserInfo([int]$TokenId) {
|
|
if($TokenId -le 0 -and [AuthenticationOAuth]::Tokens.Count -gt 0) {
|
|
$latest = [AuthenticationOAuth]::Tokens.Values | Sort-Object AcquiredAt -Descending | Select-Object -First 1
|
|
$TokenId = $latest.Id
|
|
}
|
|
if(-not [AuthenticationOAuth]::Tokens.ContainsKey($TokenId)) { return $null }
|
|
$entry = [AuthenticationOAuth]::Tokens[$TokenId]
|
|
|
|
$upn = $null; $userId = $null; $appName = $null
|
|
try {
|
|
$jwt = Get-JWTtoken $entry.AccessToken
|
|
if($jwt -and $jwt.Payload) {
|
|
$upn = if($jwt.Payload.upn) { [string]$jwt.Payload.upn }
|
|
elseif($jwt.Payload.preferred_username) { [string]$jwt.Payload.preferred_username }
|
|
elseif($jwt.Payload.unique_name) { [string]$jwt.Payload.unique_name }
|
|
else { $null }
|
|
$userId = if($jwt.Payload.oid) { [string]$jwt.Payload.oid } else { $null }
|
|
$appName = if($jwt.Payload.app_displayname) { [string]$jwt.Payload.app_displayname } else { $null }
|
|
}
|
|
} catch { }
|
|
|
|
# Map AuthMethod to the cross-provider taxonomy (Interactive / ClientCredential / BYO / ...).
|
|
$authType = switch ($entry.AuthMethod) {
|
|
'BYO' { 'BYO' }
|
|
'DeviceCode' { 'Interactive' }
|
|
'AuthCode' { 'Interactive' }
|
|
'IMDS' { 'ManagedIdentity' }
|
|
'Federated' { 'WorkloadFederation' }
|
|
'Certificate' { 'ClientCredential' }
|
|
'ClientSecret' { 'ClientCredential' }
|
|
'Password' { 'Password' }
|
|
default { [string]$entry.AuthMethod }
|
|
}
|
|
|
|
# Caller-friendly default for headless flows: when there's no UPN
|
|
# (app-only token), display the app id.
|
|
$displayName = if($upn) { $upn } else { $entry.ClientId }
|
|
|
|
# Tenant display name — one /organization GET per tenant per session,
|
|
# mirroring AuthenticationMgGraph.TenantNameCache. App-only tokens
|
|
# without Organization.Read.All fail the lookup; the $null result is
|
|
# cached too so it isn't retried on every refresh event.
|
|
$tenantName = $null
|
|
if($entry.TenantId) {
|
|
if([AuthenticationOAuth]::TenantNameCache.ContainsKey($entry.TenantId)) {
|
|
$tenantName = [AuthenticationOAuth]::TenantNameCache[$entry.TenantId]
|
|
}
|
|
else {
|
|
$tenantName = Get-OAuthTenantOrganizationName -Resource $entry.Resource -AccessToken $entry.AccessToken
|
|
[AuthenticationOAuth]::TenantNameCache[$entry.TenantId] = $tenantName
|
|
}
|
|
}
|
|
|
|
return [PSCustomObject]@{
|
|
Provider = $this.Id
|
|
DisplayName = $displayName
|
|
UPN = $upn
|
|
UserId = $userId
|
|
TenantId = $entry.TenantId
|
|
TenantName = $tenantName
|
|
AppId = $entry.ClientId
|
|
AppName = $appName
|
|
AuthType = $authType
|
|
ExpiresOn = $entry.ExpiresAt.ToLocalTime()
|
|
}
|
|
}
|
|
|
|
[PSCustomObject[]] GetCachedAccounts() {
|
|
# Pure-headless: surface the token cache as the account list. Each entry
|
|
# represents one Connect() call within the session.
|
|
$rows = @()
|
|
foreach($entry in [AuthenticationOAuth]::Tokens.Values) {
|
|
$upn = $null
|
|
try {
|
|
$jwt = Get-JWTtoken $entry.AccessToken
|
|
if($jwt -and $jwt.Payload) {
|
|
$upn = if($jwt.Payload.upn) { [string]$jwt.Payload.upn }
|
|
elseif($jwt.Payload.preferred_username) { [string]$jwt.Payload.preferred_username }
|
|
else { $null }
|
|
}
|
|
} catch { }
|
|
$rows += [PSCustomObject]@{
|
|
Provider = $this.Id
|
|
Username = if($upn) { $upn } else { $entry.ClientId }
|
|
UserId = $null
|
|
TenantId = $entry.TenantId
|
|
Native = $entry
|
|
}
|
|
}
|
|
return [PSCustomObject[]]$rows
|
|
}
|
|
|
|
[PSCustomObject[]] GetAvailableTenants([int]$TokenId) {
|
|
# Tenant enumeration costs an extra Graph call (/tenants or /me/memberOf
|
|
# /transitive). Headless callers usually know which tenant they want;
|
|
# leave this as a v2 enhancement.
|
|
return [PSCustomObject[]]@()
|
|
}
|
|
|
|
# Resolve a -Certificate argument (thumbprint string OR X509Certificate2 OR
|
|
# path-to-pfx) into a usable X509Certificate2. Reuses the MSAL provider's
|
|
# resolver where available so behavior stays identical.
|
|
Hidden [System.Security.Cryptography.X509Certificates.X509Certificate2] ResolveCertificate($CertObject, [string]$Path, $Password) {
|
|
if($CertObject -is [System.Security.Cryptography.X509Certificates.X509Certificate2]) {
|
|
return $CertObject
|
|
}
|
|
if($CertObject) {
|
|
# Treat as thumbprint string — search Cert: stores the same way MSAL does.
|
|
if(Get-Command Resolve-MSALCertificate -ErrorAction SilentlyContinue) {
|
|
$cert = Resolve-MSALCertificate $CertObject
|
|
if($cert) { return $cert }
|
|
}
|
|
# Fallback: walk Cert:\CurrentUser\My then Cert:\LocalMachine\My.
|
|
$thumb = ([string]$CertObject).Replace(' ', '').ToUpperInvariant()
|
|
foreach($store in @('Cert:\CurrentUser\My', 'Cert:\LocalMachine\My')) {
|
|
$hit = Get-ChildItem $store -ErrorAction SilentlyContinue | Where-Object Thumbprint -EQ $thumb | Select-Object -First 1
|
|
if($hit) { return $hit }
|
|
}
|
|
throw "Could not find certificate with thumbprint '$CertObject' in CurrentUser\My or LocalMachine\My"
|
|
}
|
|
if($Path) {
|
|
if(-not (Test-Path $Path)) { throw "Certificate file not found: $Path" }
|
|
return Get-PfxCertificate -FilePath $Path -Password $Password -ErrorAction Stop
|
|
}
|
|
throw "ResolveCertificate: neither object nor path was supplied"
|
|
}
|
|
}
|