Files

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"
}
}