Public/Connect-ECMA2Graph.ps1

function Connect-ECMA2Graph {
    <#
    .SYNOPSIS
        Connects to Microsoft Graph for cloud provisioning synchronization job management.

    .DESCRIPTION
        Establishes an access token used by Get-ECMA2ConnectorSyncJob and
        Restart-ECMA2ConnectorSyncJob. No Microsoft Graph SDK or MSAL dependency is
        required - authentication is performed with direct REST calls against the
        Microsoft identity platform v2.0 endpoint, keeping this module dependency-free.

        Four mutually exclusive authentication modes are supported:
        - Interactive (default): device code flow using delegated user permissions -
          the same sign-in experience as Graph Explorer. No app registration required
          unless your tenant restricts the well-known Microsoft Graph PowerShell client.
        - ClientSecret: app-only client credentials using a client secret, for automation.
        - Certificate: app-only client credentials using a signed JWT certificate
          assertion, for automation without storing a shared secret.
        - AccessToken: use a token already obtained some other way (e.g. copied from
          Graph Explorer). This mode cannot self-refresh; reconnect when it expires.
          ExpiresOn is read from the token's own 'exp' claim when it's a parseable
          JWT, falling back to 60 minutes from now otherwise.

    .PARAMETER TenantId
        The tenant ID or verified domain to authenticate against. Defaults to
        'organizations' for interactive sign-in. Required for ClientSecret and
        Certificate modes.

    .PARAMETER ClientId
        The application (client) ID to authenticate as. Defaults to the well-known
        Microsoft Graph PowerShell client ID for interactive sign-in. Required for
        ClientSecret and Certificate modes.

    .PARAMETER Scopes
        Delegated scopes to request in Interactive mode. Defaults to
        'Synchronization.ReadWrite.All' plus 'offline_access' (needed to obtain a
        refresh token).

    .PARAMETER ClientSecret
        App-only authentication: the application's client secret.

    .PARAMETER CertificateThumbprint
        App-only authentication: thumbprint of a certificate (with private key) in
        Cert:\CurrentUser\My or Cert:\LocalMachine\My.

    .PARAMETER Certificate
        App-only authentication: an X509Certificate2 object (with private key) to sign with.

    .PARAMETER AccessToken
        Bring-your-own bearer token, as a SecureString.

    .EXAMPLE
        Connect-ECMA2Graph
        Signs in interactively via device code, matching the Graph Explorer experience.

    .EXAMPLE
        Connect-ECMA2Graph -TenantId contoso.onmicrosoft.com -ClientId $appId -ClientSecret (Read-Host -AsSecureString)
        Connects using app-only client credentials for unattended automation.

    .EXAMPLE
        Connect-ECMA2Graph -TenantId contoso.onmicrosoft.com -ClientId $appId -CertificateThumbprint $thumbprint
        Connects using app-only certificate authentication.

    .NOTES
        Requires the app registration (for ClientSecret/Certificate modes) or the
        signed-in delegated user (for Interactive mode) to hold Synchronization.ReadWrite.All,
        and for delegated sign-in the user must also be an owner/member of the target
        service principal or hold Application Administrator, Cloud Application
        Administrator, or Hybrid Identity Administrator.
    #>

    [CmdletBinding(DefaultParameterSetName = 'Interactive')]
    param(
        [Parameter(ParameterSetName = 'Interactive')]
        [Parameter(ParameterSetName = 'ClientSecret', Mandatory)]
        [Parameter(ParameterSetName = 'Certificate', Mandatory)]
        [string]$TenantId = 'organizations',

        [Parameter(ParameterSetName = 'Interactive')]
        [Parameter(ParameterSetName = 'ClientSecret', Mandatory)]
        [Parameter(ParameterSetName = 'Certificate', Mandatory)]
        [string]$ClientId = '14d82eec-204b-4c2f-b7e8-296a70dab67e',

        [Parameter(ParameterSetName = 'Interactive')]
        [string[]]$Scopes = @('Synchronization.ReadWrite.All', 'offline_access'),

        [Parameter(ParameterSetName = 'ClientSecret', Mandatory)]
        [SecureString]$ClientSecret,

        [Parameter(ParameterSetName = 'Certificate')]
        [string]$CertificateThumbprint,

        [Parameter(ParameterSetName = 'Certificate')]
        [System.Security.Cryptography.X509Certificates.X509Certificate2]$Certificate,

        [Parameter(ParameterSetName = 'AccessToken', Mandatory)]
        [SecureString]$AccessToken
    )

    try {
        switch ($PSCmdlet.ParameterSetName) {
            'Interactive' {
                $deviceCodeEndpoint = "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/devicecode"
                $tokenEndpoint = "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/token"

                $deviceCodeResponse = Invoke-RestMethod -Method Post -Uri $deviceCodeEndpoint -Body @{
                    client_id = $ClientId
                    scope     = ($Scopes -join ' ')
                } -ContentType 'application/x-www-form-urlencoded'

                Write-Host $deviceCodeResponse.message -ForegroundColor Cyan

                $expiresAt = (Get-Date).AddSeconds($deviceCodeResponse.expires_in)
                $tokenResponse = $null
                $pollingInterval = $deviceCodeResponse.interval

                while ((Get-Date) -lt $expiresAt) {
                    Start-Sleep -Seconds $pollingInterval
                    try {
                        $tokenResponse = Invoke-RestMethod -Method Post -Uri $tokenEndpoint -Body @{
                            grant_type  = 'urn:ietf:params:oauth:grant-type:device_code'
                            client_id   = $ClientId
                            device_code = $deviceCodeResponse.device_code
                        } -ContentType 'application/x-www-form-urlencoded'
                        break
                    }
                    catch {
                        $errorBody = $null
                        if ($_.ErrorDetails.Message) {
                            $errorBody = $_.ErrorDetails.Message | ConvertFrom-Json -ErrorAction SilentlyContinue
                        }
                        if ($errorBody -and $errorBody.error -eq 'authorization_pending') {
                            continue
                        }
                        if ($errorBody -and $errorBody.error -eq 'slow_down') {
                            # Per RFC 8628, back off polling by 5 seconds and keep waiting.
                            $pollingInterval += 5
                            continue
                        }
                        if ($errorBody -and $errorBody.error_description) {
                            throw "Device code sign-in failed: $($errorBody.error_description)"
                        }
                        throw "Device code sign-in failed: $_"
                    }
                }

                if (-not $tokenResponse) {
                    throw "Device code sign-in timed out before completion."
                }

                $script:ECMA2GraphContext = [PSCustomObject]@{
                    AuthMode     = 'DeviceCode'
                    TenantId     = $TenantId
                    ClientId     = $ClientId
                    AccessToken  = $tokenResponse.access_token
                    RefreshToken = $tokenResponse.refresh_token
                    Certificate  = $null
                    ClientSecret = $null
                    Scopes       = $Scopes
                    ExpiresOn    = (Get-Date).AddSeconds($tokenResponse.expires_in)
                }
            }
            'ClientSecret' {
                $tokenEndpoint = "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/token"
                $bstr = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($ClientSecret)
                $plainSecret = [System.Runtime.InteropServices.Marshal]::PtrToStringAuto($bstr)
                [System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr)

                $tokenResponse = Invoke-RestMethod -Method Post -Uri $tokenEndpoint -Body @{
                    grant_type    = 'client_credentials'
                    client_id     = $ClientId
                    client_secret = $plainSecret
                    scope         = 'https://graph.microsoft.com/.default'
                } -ContentType 'application/x-www-form-urlencoded'

                $script:ECMA2GraphContext = [PSCustomObject]@{
                    AuthMode     = 'ClientSecret'
                    TenantId     = $TenantId
                    ClientId     = $ClientId
                    AccessToken  = $tokenResponse.access_token
                    RefreshToken = $null
                    Certificate  = $null
                    ClientSecret = $ClientSecret
                    Scopes       = @('https://graph.microsoft.com/.default')
                    ExpiresOn    = (Get-Date).AddSeconds($tokenResponse.expires_in)
                }
            }
            'Certificate' {
                if (-not $Certificate) {
                    if (-not $CertificateThumbprint) {
                        throw "Specify either -Certificate or -CertificateThumbprint."
                    }
                    $Certificate = Get-Item -Path "Cert:\CurrentUser\My\$CertificateThumbprint" -ErrorAction SilentlyContinue
                    if (-not $Certificate) {
                        $Certificate = Get-Item -Path "Cert:\LocalMachine\My\$CertificateThumbprint" -ErrorAction SilentlyContinue
                    }
                    if (-not $Certificate) {
                        throw "Certificate with thumbprint '$CertificateThumbprint' not found in CurrentUser\My or LocalMachine\My."
                    }
                }

                $tokenEndpoint = "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/token"
                $assertion = New-ECMA2GraphClientAssertion -ClientId $ClientId -TenantId $TenantId -Certificate $Certificate

                $tokenResponse = Invoke-RestMethod -Method Post -Uri $tokenEndpoint -Body @{
                    grant_type            = 'client_credentials'
                    client_id             = $ClientId
                    client_assertion_type = 'urn:ietf:params:oauth:client-assertion-type:jwt-bearer'
                    client_assertion      = $assertion
                    scope                 = 'https://graph.microsoft.com/.default'
                } -ContentType 'application/x-www-form-urlencoded'

                $script:ECMA2GraphContext = [PSCustomObject]@{
                    AuthMode     = 'Certificate'
                    TenantId     = $TenantId
                    ClientId     = $ClientId
                    AccessToken  = $tokenResponse.access_token
                    RefreshToken = $null
                    Certificate  = $Certificate
                    ClientSecret = $null
                    Scopes       = @('https://graph.microsoft.com/.default')
                    ExpiresOn    = (Get-Date).AddSeconds($tokenResponse.expires_in)
                }
            }
            'AccessToken' {
                $bstr = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($AccessToken)
                $plainToken = [System.Runtime.InteropServices.Marshal]::PtrToStringAuto($bstr)
                [System.Runtime.InteropServices.Marshal]::ZeroFreeBSTR($bstr)

                # Best-effort: read the 'exp' claim out of the JWT payload so ExpiresOn
                # reflects the token's real expiry rather than an arbitrary guess. Falls
                # back to a 60-minute default if the token isn't a parseable JWT.
                $expiresOn = (Get-Date).AddMinutes(60)
                try {
                    $payload = $plainToken.Split('.')[1]
                    $payload = $payload.Replace('-', '+').Replace('_', '/')
                    switch ($payload.Length % 4) {
                        2 { $payload += '==' }
                        3 { $payload += '=' }
                    }
                    $claims = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String($payload)) | ConvertFrom-Json
                    if ($claims.exp) {
                        $expiresOn = [System.DateTimeOffset]::FromUnixTimeSeconds([int64]$claims.exp).LocalDateTime
                    }
                }
                catch {
                    Write-Verbose "Could not parse an 'exp' claim from the supplied access token; defaulting ExpiresOn to 60 minutes from now."
                }

                $script:ECMA2GraphContext = [PSCustomObject]@{
                    AuthMode     = 'AccessToken'
                    TenantId     = $null
                    ClientId     = $null
                    AccessToken  = $plainToken
                    RefreshToken = $null
                    Certificate  = $null
                    ClientSecret = $null
                    Scopes       = @()
                    ExpiresOn    = $expiresOn
                }
                Write-Warning "Bring-your-own access token cannot be automatically refreshed. Reconnect with a fresh token if requests start failing with 401 or once ExpiresOn ($expiresOn) passes."
            }
        }

        Write-Verbose "Connected to Microsoft Graph using $($script:ECMA2GraphContext.AuthMode) authentication"

        [PSCustomObject]@{
            PSTypeName = 'ECMA2Host.GraphContext'
            AuthMode   = $script:ECMA2GraphContext.AuthMode
            TenantId   = $script:ECMA2GraphContext.TenantId
            ClientId   = $script:ECMA2GraphContext.ClientId
            Scopes     = $script:ECMA2GraphContext.Scopes
            ExpiresOn  = $script:ECMA2GraphContext.ExpiresOn
        }
    }
    catch {
        Write-Error "Failed to connect to Microsoft Graph: $_"
    }
}