Public/Connect-UTCM.ps1
|
function Connect-UTCM { <# .SYNOPSIS Authenticates to Microsoft Graph and stores the access token for subsequent calls. .DESCRIPTION Supports three modes: - Interactive (delegated): Authorization-code flow with PKCE. Opens a browser with account picker for sign-in. Requests explicit UTCM scopes so the user can consent. Defaults to the well-known Microsoft Graph PowerShell app. - Client Credentials (application): client_credentials grant. Requires TenantId, ClientId, and ClientSecret. - Token: Bring your own access token. After connecting, displays account context (user, tenant, scopes, expiry). .PARAMETER TenantId Azure AD / Entra ID tenant ID (GUID or domain). .PARAMETER ClientId Application (client) ID. For interactive flow this defaults to the well-known Microsoft Graph PowerShell app (14d82eec-204b-4c2f-b7e8-296a70dab67e). .PARAMETER ClientSecret Client secret for the app registration (application flow only). .PARAMETER Scopes Space-separated scopes to request. For interactive flow, defaults to the UTCM delegated scopes so consent is properly prompted. For client credentials, defaults to 'https://graph.microsoft.com/.default'. .PARAMETER AccessToken Provide an already-acquired access token directly (skips token acquisition). .EXAMPLE Connect-UTCM -TenantId "contoso.onmicrosoft.com" .EXAMPLE Connect-UTCM -TenantId "contoso.onmicrosoft.com" -ClientId "00000000-..." .EXAMPLE Connect-UTCM -TenantId "contoso.onmicrosoft.com" -ClientId "00000000-..." -ClientSecret "s3cret!" .EXAMPLE Connect-UTCM -AccessToken $myToken #> [CmdletBinding(DefaultParameterSetName = 'Interactive')] param( [Parameter(Mandatory, ParameterSetName = 'Interactive')] [Parameter(Mandatory, ParameterSetName = 'ClientCredential')] [string]$TenantId, [Parameter(ParameterSetName = 'Interactive')] [Parameter(Mandatory, ParameterSetName = 'ClientCredential')] [string]$ClientId, [Parameter(Mandatory, ParameterSetName = 'ClientCredential')] [string]$ClientSecret, [Parameter(ParameterSetName = 'Interactive')] [Parameter(ParameterSetName = 'ClientCredential')] [string]$Scopes, [Parameter(Mandatory, ParameterSetName = 'Token')] [string]$AccessToken ) # --- Bring-your-own-token --- if ($PSCmdlet.ParameterSetName -eq 'Token') { $script:Token = $AccessToken $script:TokenExpiry = (Get-Date).AddHours(1) $script:RefreshToken = $null # No refresh capability with BYOT $script:TokenEndpoint = $null $script:ClientId = $null $script:Context = Get-UTCMTokenContext -Token $AccessToken Write-UTCMContext 'Provided token' return } # Fall back to the well-known Graph PowerShell app ID for interactive flow if (-not $ClientId) { $ClientId = $script:GraphPSAppId } $tokenEndpoint = "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/token" if ($PSCmdlet.ParameterSetName -eq 'ClientCredential') { # --- Client Credentials --- if (-not $Scopes) { $Scopes = 'https://graph.microsoft.com/.default' } $body = @{ client_id = $ClientId scope = $Scopes client_secret = $ClientSecret grant_type = 'client_credentials' } $response = Invoke-RestMethod -Uri $tokenEndpoint -Method Post -Body $body -ContentType 'application/x-www-form-urlencoded' $script:Token = $response.access_token $script:TokenExpiry = (Get-Date).AddSeconds($response.expires_in - 60) $script:RefreshToken = $null # Client credentials don't get refresh tokens $script:TokenEndpoint = $null $script:ClientId = $null $script:Context = Get-UTCMTokenContext -Token $response.access_token Write-UTCMContext 'Client credentials' } else { # --- Authorization Code + PKCE (browser-based interactive login) --- if (-not $Scopes) { $Scopes = $script:DefaultScopes } # 1. Generate PKCE code verifier & challenge $codeVerifierBytes = [byte[]]::new(32) [System.Security.Cryptography.RandomNumberGenerator]::Fill($codeVerifierBytes) $codeVerifier = [Convert]::ToBase64String($codeVerifierBytes) -replace '\+','-' -replace '/','_' -replace '=' $challengeHash = [System.Security.Cryptography.SHA256]::HashData( [System.Text.Encoding]::ASCII.GetBytes($codeVerifier) ) $codeChallenge = [Convert]::ToBase64String($challengeHash) -replace '\+','-' -replace '/','_' -replace '=' # 2. Pick a random localhost port and set up a temporary HTTP listener $port = Get-Random -Minimum 49152 -Maximum 65535 $redirectUri = "http://localhost:$port/" $listener = [System.Net.HttpListener]::new() $listener.Prefixes.Add($redirectUri) $listener.Start() # 3. Build and open the authorize URL (prompt=select_account forces account picker) $state = [guid]::NewGuid().ToString('N') $authUrl = "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/authorize?" + ( @( "client_id=$ClientId" "response_type=code" "redirect_uri=$([uri]::EscapeDataString($redirectUri))" "response_mode=query" "scope=$([uri]::EscapeDataString($Scopes))" "state=$state" "code_challenge=$codeChallenge" "code_challenge_method=S256" "prompt=select_account" ) -join '&' ) Write-Host "[UTCM] Opening browser for sign-in..." -ForegroundColor Yellow Start-Process $authUrl # 4. Wait for the redirect (browser posts back) try { $context = $listener.GetContext() # blocks until browser redirects $query = $context.Request.QueryString # Return a friendly page to the user $html = '<html><body><h3>Authentication complete — you can close this tab.</h3></body></html>' $buffer = [System.Text.Encoding]::UTF8.GetBytes($html) $context.Response.ContentLength64 = $buffer.Length $context.Response.OutputStream.Write($buffer, 0, $buffer.Length) $context.Response.OutputStream.Close() # Validate state if ($query['state'] -ne $state) { throw "State mismatch — possible CSRF. Aborting." } if ($query['error']) { throw "Authorization error: $($query['error']) — $($query['error_description'])" } $authCode = $query['code'] } finally { $listener.Stop() $listener.Close() } # 5. Exchange auth code + verifier for tokens $tokenBody = @{ client_id = $ClientId scope = $Scopes code = $authCode redirect_uri = $redirectUri grant_type = 'authorization_code' code_verifier = $codeVerifier } $tokenResponse = Invoke-RestMethod -Uri $tokenEndpoint -Method Post -Body $tokenBody -ContentType 'application/x-www-form-urlencoded' $script:Token = $tokenResponse.access_token $script:TokenExpiry = (Get-Date).AddSeconds($tokenResponse.expires_in - 60) $script:RefreshToken = $tokenResponse.refresh_token # Store for silent refresh $script:TokenEndpoint = $tokenEndpoint $script:ClientId = $ClientId $script:Context = Get-UTCMTokenContext -Token $tokenResponse.access_token Write-UTCMContext 'Interactive browser' } } |