Modules/MDOP2.Auth.psm1

Set-StrictMode -Version Latest

Import-Module (Join-Path $PSScriptRoot 'MDOP2.Common.psm1')

$script:GraphAppId = '00000003-0000-0000-c000-000000000000'
$script:GraphBase = 'https://graph.microsoft.com/v1.0'
$script:JitPrefix = 'phishtool-jit-'
$script:DelegatedScopes = @('Application.ReadWrite.All', 'AppRoleAssignment.ReadWrite.All')
# Microsoft Graph Command Line Tools, the public client that Connect-MgGraph uses by default.
$script:GraphCliClientId = '14d82eec-204b-4c2f-b7e8-296a70dab67e'
$script:DelegatedScopeString = (@($script:DelegatedScopes | ForEach-Object { "https://graph.microsoft.com/$_" }) + 'openid', 'profile', 'offline_access') -join ' '
$script:LoginBase = 'https://login.microsoftonline.com'
$script:Session = $null

#region Pure helpers (covered by Pester)

function New-MDOP2JitSecretName {
    [CmdletBinding()]
    [OutputType([string])]
    param([datetime]$Now = [datetime]::UtcNow)
    $script:JitPrefix + $Now.ToUniversalTime().ToString('yyyyMMdd-HHmmss')
}

function Select-MDOP2JitCredential {
    <#
    .SYNOPSIS
    Returns the password credentials that were created by this tool, based on the display name prefix.
    #>

    [CmdletBinding()]
    param([AllowNull()][object[]]$PasswordCredentials)
    if (-not $PasswordCredentials) { return @() }
    @($PasswordCredentials | Where-Object {
        $name = if ($_ -is [System.Collections.IDictionary]) { $_['displayName'] } else { $_.displayName }
        $name -and $name.StartsWith($script:JitPrefix, [StringComparison]::Ordinal)
    })
}

function ConvertFrom-MDOP2Jwt {
    <#
    .SYNOPSIS
    Decodes the payload of a JWT without validating it. Only used to read the roles claim of our own token.
    #>

    [CmdletBinding()]
    param([Parameter(Mandatory)][string]$Token)
    $parts = $Token.Split('.')
    if ($parts.Count -lt 2) { throw 'Not a JWT.' }
    $payload = $parts[1].Replace('-', '+').Replace('_', '/')
    switch ($payload.Length % 4) {
        2 { $payload += '==' }
        3 { $payload += '=' }
        1 { throw 'Invalid JWT payload length.' }
    }
    $json = [Text.Encoding]::UTF8.GetString([Convert]::FromBase64String($payload))
    $json | ConvertFrom-Json
}

function Test-MDOP2TokenHasRole {
    [CmdletBinding()]
    [OutputType([bool])]
    param(
        [Parameter(Mandatory)][string]$Token,
        [Parameter(Mandatory)][string]$Role
    )
    $claims = ConvertFrom-MDOP2Jwt -Token $Token
    $roles = if ($claims.PSObject.Properties['roles']) { @($claims.roles) } else { @() }
    $roles -contains $Role
}

function ConvertTo-MDOP2Base64Url {
    [CmdletBinding()]
    [OutputType([string])]
    param([Parameter(Mandatory)][byte[]]$Bytes)
    [Convert]::ToBase64String($Bytes).TrimEnd('=').Replace('+', '-').Replace('/', '_')
}

function New-MDOP2PkcePair {
    <#
    .SYNOPSIS
    Returns a PKCE code verifier (32 random bytes, base64url) and its S256 code challenge.
    #>

    [CmdletBinding()]
    param()
    $bytes = [byte[]]::new(32)
    [Security.Cryptography.RandomNumberGenerator]::Fill($bytes)
    $verifier = ConvertTo-MDOP2Base64Url -Bytes $bytes
    $hash = [Security.Cryptography.SHA256]::HashData([Text.Encoding]::ASCII.GetBytes($verifier))
    [pscustomobject]@{ Verifier = $verifier; Challenge = (ConvertTo-MDOP2Base64Url -Bytes $hash) }
}

function New-MDOP2AuthorizeUrl {
    [CmdletBinding()]
    [OutputType([string])]
    param(
        [Parameter(Mandatory)][string]$Tenant,
        [Parameter(Mandatory)][string]$RedirectUri,
        [Parameter(Mandatory)][string]$CodeChallenge,
        [Parameter(Mandatory)][string]$State,
        [string]$ClientId = $script:GraphCliClientId,
        [string]$Scope = $script:DelegatedScopeString
    )
    $query = [ordered]@{
        client_id             = $ClientId
        response_type         = 'code'
        redirect_uri          = $RedirectUri
        response_mode         = 'query'
        scope                 = $Scope
        state                 = $State
        code_challenge        = $CodeChallenge
        code_challenge_method = 'S256'
        prompt                = 'select_account'
    }
    $pairs = foreach ($key in $query.Keys) { "$key=$([uri]::EscapeDataString($query[$key]))" }
    "https://login.microsoftonline.com/$([uri]::EscapeDataString($Tenant))/oauth2/v2.0/authorize?$($pairs -join '&')"
}

function Read-MDOP2AuthCallback {
    <#
    .SYNOPSIS
    Reads the query string of the sign-in redirect. Returns the authorization code, $null when the state
    doesn't match (not our request), or throws when the sign-in returned an error.
    #>

    [CmdletBinding()]
    param(
        [AllowEmptyString()][string]$Query,
        [Parameter(Mandatory)][string]$ExpectedState
    )
    $values = @{}
    foreach ($pair in $Query.TrimStart('?').Split('&', [StringSplitOptions]::RemoveEmptyEntries)) {
        $kv = $pair.Split('=', 2)
        $name = [uri]::UnescapeDataString($kv[0].Replace('+', ' '))
        $values[$name] = if ($kv.Count -eq 2) { [uri]::UnescapeDataString($kv[1].Replace('+', ' ')) } else { '' }
    }
    if ($values['error']) {
        # Only printable characters, cut short: this text comes from a request to our listener.
        $desc = [string]$values['error_description']
        $desc = (($desc -split "`r?`n")[0] -replace '[^\x20-\x7E]', '')
        if ($desc.Length -gt 300) { $desc = $desc.Substring(0, 300) }
        $err = $values['error'] -replace '[^A-Za-z0-9_]', ''
        throw ("Sign-in failed: $err $desc".TrimEnd())
    }
    if ($values['state'] -cne $ExpectedState) { return $null }
    if (-not $values['code']) { return $null }
    $values['code']
}

function Get-MDOP2MissingScope {
    <#
    .SYNOPSIS
    Compares the space separated scope list of a token response with the required Graph scopes.
    #>

    [CmdletBinding()]
    param(
        [AllowEmptyString()][string]$Granted,
        [Parameter(Mandatory)][string[]]$Required
    )
    $have = @($Granted.Split(' ', [StringSplitOptions]::RemoveEmptyEntries) |
        ForEach-Object { $_ -replace '^https://graph\.microsoft\.com/', '' })
    , @($Required | Where-Object { $have -notcontains $_ })
}

#endregion

#region Delegated Graph (admin sign-in)

function Read-MDOP2TokenResponse {
    # Returns the parsed token response, or throws with the first line of error_description (it has the AADSTS code).
    param([Parameter(Mandatory)]$Response, [Parameter(Mandatory)][string]$What)
    $json = $null
    try { $json = $Response.Content | ConvertFrom-Json } catch { }
    if ([int]$Response.StatusCode -eq 200 -and $json -and $json.PSObject.Properties['access_token']) { return $json }
    $desc = if ($json -and $json.PSObject.Properties['error_description']) { ($json.error_description -split "`r?`n")[0] } else { "HTTP $([int]$Response.StatusCode)" }
    throw "$What failed: $desc"
}

function Set-MDOP2DelegatedToken {
    param([Parameter(Mandatory)]$Session, [Parameter(Mandatory)]$TokenResponse)
    $Session.DelegatedToken = $TokenResponse.access_token
    $Session.DelegatedExpiry = [datetime]::UtcNow.AddSeconds([int]$TokenResponse.expires_in - 300)
    if ($TokenResponse.PSObject.Properties['refresh_token'] -and $TokenResponse.refresh_token) {
        # Always keep the newest refresh token and drop the old one.
        if ($Session.RefreshToken) { $Session.RefreshToken.Dispose() }
        $Session.RefreshToken = ConvertTo-SecureString -String $TokenResponse.refresh_token -AsPlainText -Force
    }
}

function Get-MDOP2DelegatedToken {
    <#
    .SYNOPSIS
    Returns the admin's delegated Graph token. Renewed with the refresh token 5 minutes before expiry.
    #>

    [CmdletBinding()]
    param()
    $session = Get-MDOP2Session
    if ($session.DelegatedToken -and [datetime]::UtcNow -lt $session.DelegatedExpiry) { return $session.DelegatedToken }
    if (-not $session.RefreshToken) { throw 'The admin sign-in has expired. Start the tool again.' }
    $body = @{
        client_id     = $script:GraphCliClientId
        grant_type    = 'refresh_token'
        refresh_token = [Net.NetworkCredential]::new('', $session.RefreshToken).Password
        scope         = $script:DelegatedScopeString
    }
    $resp = Invoke-WebRequest -Method Post -Uri "$script:LoginBase/$($session.TenantId)/oauth2/v2.0/token" -Body $body -ContentType 'application/x-www-form-urlencoded' -SkipHttpErrorCheck
    $body = $null
    $json = Read-MDOP2TokenResponse -Response $resp -What 'Renewing the admin sign-in'
    Set-MDOP2DelegatedToken -Session $session -TokenResponse $json
    $session.DelegatedToken
}

function Resolve-MDOP2GraphUri {
    # A path goes to Graph v1.0. A full URL (an @odata.nextLink) must point at Graph, so a token never goes anywhere else.
    param([Parameter(Mandatory)][string]$Path)
    if ($Path.StartsWith('/')) { return "$script:GraphBase$Path" }
    if ($Path.StartsWith('https://graph.microsoft.com/', [StringComparison]::OrdinalIgnoreCase)) { return $Path }
    throw 'Refusing to send the Graph token to a URL outside https://graph.microsoft.com/.'
}

function Get-MDOP2RetryAfter {
    param([Parameter(Mandatory)]$Response)
    if ($Response.Headers.ContainsKey('Retry-After')) { [string]$Response.Headers['Retry-After'][0] }
}

function New-MDOP2GraphError {
    # The exception for a failed Graph call, with the HTTP status and the Graph error code in Data
    param([Parameter(Mandatory)][string]$Method, [Parameter(Mandatory)]$Response)
    $status = [int]$Response.StatusCode
    $code = $null; $message = $Response.Content
    try {
        $err = ($Response.Content | ConvertFrom-Json).error
        $code = $err.code; $message = $err.message
    } catch { }
    $ex = [System.Exception]::new("Graph $Method failed with HTTP ${status}: $code $message")
    $ex.Data['StatusCode'] = $status
    $ex.Data['ErrorCode'] = $code
    $ex
}

function Invoke-MDOP2GraphRequest {
    # One Graph call with the admin's token (-Delegated) or the app-only token. Retries 429/5xx and the extra statuses
    # in RetryStatus with Retry-After or backoff. Returns the response, or throws New-MDOP2GraphError.
    param(
        [Parameter(Mandatory)][string]$Method,
        [Parameter(Mandatory)][string]$Path,
        [object]$Body,
        [int]$MaxAttempts = 6,
        [switch]$Delegated,
        [int[]]$RetryStatus = @()
    )
    $uri = Resolve-MDOP2GraphUri -Path $Path
    $json = if ($null -ne $Body) { $Body | ConvertTo-Json -Depth 10 -Compress } else { $null }
    for ($attempt = 1; ; $attempt++) {
        $token = if ($Delegated) { Get-MDOP2DelegatedToken } else { Get-MDOP2AppToken }
        $params = @{ Method = $Method; Uri = $uri; Headers = @{ Authorization = "Bearer $token" }; SkipHttpErrorCheck = $true }
        if ($null -ne $json) { $params.Body = $json; $params.ContentType = 'application/json' }
        $resp = Invoke-WebRequest @params
        $status = [int]$resp.StatusCode
        if ($status -ge 200 -and $status -lt 300) { return $resp }
        $retryable = (Test-MDOP2RetryableStatus -StatusCode $status) -or $status -in $RetryStatus
        if (-not $retryable -or $attempt -ge $MaxAttempts) { throw (New-MDOP2GraphError -Method $Method -Response $resp) }
        $delay = Get-MDOP2RetryDelay -Attempt $attempt -RetryAfter (Get-MDOP2RetryAfter -Response $resp)
        Write-Verbose "Graph returned $status for $Method. Retrying in $delay s."
        Start-Sleep -Seconds $delay
    }
}

function Invoke-MDOP2DelegatedGraph {
    <#
    .SYNOPSIS
    Calls Microsoft Graph with the admin's delegated token. Only used to manage the app registration and its secret.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][ValidateSet('GET', 'POST', 'PATCH', 'DELETE')][string]$Method,
        [Parameter(Mandatory)][string]$Path,
        [object]$Body,
        [int]$MaxAttempts = 6,
        # Directory objects that were just created can take a moment to replicate. Retry 400/404 when set.
        [switch]$RetryOnReplication
    )
    $retry = if ($RetryOnReplication) { 400, 404 } else { @() }
    $resp = Invoke-MDOP2GraphRequest -Delegated -Method $Method -Path $Path -Body $Body -MaxAttempts $MaxAttempts -RetryStatus $retry
    if ($resp.Content) { $resp.Content | ConvertFrom-Json }
}

function Send-MDOP2ListenerReply {
    param([Parameter(Mandatory)]$Context, [int]$Status = 200, [string]$Text = '')
    try {
        $html = "<!doctype html><meta charset=`"utf-8`"><title>Invoke-MDOP2-Incident-Response</title><body style=`"font-family:sans-serif;margin:3em`"><p>$([Net.WebUtility]::HtmlEncode($Text))</p></body>"
        $bytes = [Text.Encoding]::UTF8.GetBytes($html)
        $Context.Response.StatusCode = $Status
        $Context.Response.ContentType = 'text/html; charset=utf-8'
        $Context.Response.Headers['Cache-Control'] = 'no-store'
        $Context.Response.Headers['Content-Security-Policy'] = "default-src 'none'; style-src 'unsafe-inline'"
        $Context.Response.ContentLength64 = $bytes.Length
        $Context.Response.OutputStream.Write($bytes, 0, $bytes.Length)
    }
    catch { Write-Verbose "Could not answer the browser: $_" }
    finally { try { $Context.Response.Close() } catch { } }
}

function Invoke-MDOP2BrowserSignIn {
    <#
    .SYNOPSIS
    Signs the admin in with the OAuth authorization code flow and PKCE in a browser, with a redirect to a
    listener on localhost. Doesn't use the Windows sign-in broker (WAM). Returns the token response.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][string]$Tenant,
        [switch]$NoBrowser,
        [int]$TimeoutSeconds = 300
    )
    $redirectUri = "http://localhost:$(Get-MDOP2FreePort)/"
    $pkce = New-MDOP2PkcePair
    $stateBytes = [byte[]]::new(16)
    [Security.Cryptography.RandomNumberGenerator]::Fill($stateBytes)
    $state = ConvertTo-MDOP2Base64Url -Bytes $stateBytes
    $url = New-MDOP2AuthorizeUrl -Tenant $Tenant -RedirectUri $redirectUri -CodeChallenge $pkce.Challenge -State $state

    $listener = [Net.HttpListener]::new()
    $listener.Prefixes.Add($redirectUri)
    $listener.Start()
    try {
        Write-Host 'Sign in to Microsoft Graph in your browser. If it does not open, open this link:'
        Write-Host $url
        if (-not $NoBrowser) { Start-Process $url }

        $deadline = [datetime]::UtcNow.AddSeconds($TimeoutSeconds)
        $code = $null
        $pending = $listener.GetContextAsync()
        while (-not $code) {
            # Short waits so Ctrl+C still works.
            if (-not $pending.Wait(500)) {
                if ([datetime]::UtcNow -ge $deadline) { throw "Sign-in timed out after $TimeoutSeconds seconds." }
                continue
            }
            $ctx = $pending.Result
            $pending = $null
            if ($ctx.Request.Url.AbsolutePath -ne '/' -or $ctx.Request.HttpMethod -ne 'GET') {
                Send-MDOP2ListenerReply -Context $ctx -Status 404 -Text 'Not found.'
            }
            else {
                try { $code = Read-MDOP2AuthCallback -Query $ctx.Request.Url.Query -ExpectedState $state }
                catch {
                    Send-MDOP2ListenerReply -Context $ctx -Status 400 -Text 'Sign-in failed. See the PowerShell window.'
                    throw
                }
                if ($code) { Send-MDOP2ListenerReply -Context $ctx -Text 'Signed in. You can close this tab and return to PowerShell.' }
                else { Send-MDOP2ListenerReply -Context $ctx -Status 400 -Text 'Unexpected request.' }
            }
            if (-not $code) { $pending = $listener.GetContextAsync() }
        }
    }
    finally {
        $listener.Stop()
        $listener.Close()
    }

    $body = @{
        client_id     = $script:GraphCliClientId
        grant_type    = 'authorization_code'
        code          = $code
        redirect_uri  = $redirectUri
        code_verifier = $pkce.Verifier
        scope         = $script:DelegatedScopeString
    }
    $resp = Invoke-WebRequest -Method Post -Uri "$script:LoginBase/$Tenant/oauth2/v2.0/token" -Body $body -ContentType 'application/x-www-form-urlencoded' -SkipHttpErrorCheck
    $body = $null; $code = $null
    Read-MDOP2TokenResponse -Response $resp -What 'Sign-in'
}

function Connect-MDOP2Admin {
    <#
    .SYNOPSIS
    Signs the admin in to Microsoft Graph and Exchange Online in a browser. Tokens are kept in memory only.
    #>

    [CmdletBinding()]
    param(
        [string]$TenantId,
        [switch]$SkipExchange,
        # Print the Graph sign-in link instead of opening it in the default browser.
        [switch]$NoBrowser
    )
    $tenant = if ($TenantId) { $TenantId } else { 'organizations' }
    $tokens = Invoke-MDOP2BrowserSignIn -Tenant $tenant -NoBrowser:$NoBrowser

    if (-not $tokens.PSObject.Properties['id_token']) { throw 'Sign-in returned no ID token.' }
    $claims = ConvertFrom-MDOP2Jwt -Token $tokens.id_token
    $tid = $claims.tid
    $upn = if ($claims.PSObject.Properties['preferred_username']) { $claims.preferred_username } else { $null }
    if (-not $tid -or -not $upn) { throw 'Sign-in did not return the tenant or account name.' }
    $granted = if ($tokens.PSObject.Properties['scope']) { [string]$tokens.scope } else { '' }
    $missing = Get-MDOP2MissingScope -Granted $granted -Required $script:DelegatedScopes
    if ($missing.Count) { throw "Sign-in is missing the delegated scopes: $($missing -join ', ')." }

    $session = [ordered]@{
        TenantId        = $tid
        AdminUpn        = $upn
        DelegatedToken  = $null
        DelegatedExpiry = [datetime]::MinValue
        RefreshToken    = $null
        AppId           = $null
        AppObjectId     = $null
        SecretKeyId     = $null
        Secret          = $null
        Token           = $null
        TokenExpiry     = [datetime]::MinValue
    }
    Set-MDOP2DelegatedToken -Session $session -TokenResponse $tokens
    $tokens = $null
    $script:Session = $session

    if (-not $SkipExchange) {
        Write-Host 'Sign in to Exchange Online in the browser window that opens.'
        Connect-ExchangeOnline -UserPrincipalName $upn -DisableWAM -ShowBanner:$false -ErrorAction Stop
    }
    [pscustomobject]@{ TenantId = $tid; AdminUpn = $upn }
}

function Disconnect-MDOP2Admin {
    [CmdletBinding()]
    param()
    try { Disconnect-ExchangeOnline -Confirm:$false -ErrorAction Stop | Out-Null } catch { Write-Verbose "Exchange disconnect: $_" }
    if ($script:Session) {
        if ($script:Session.RefreshToken) { $script:Session.RefreshToken.Dispose() }
        $script:Session.RefreshToken = $null
        $script:Session.DelegatedToken = $null
    }
    $script:Session = $null
}

function Get-MDOP2Session {
    if (-not $script:Session) { throw 'Not signed in. Call Connect-MDOP2Admin first.' }
    $script:Session
}

#endregion

#region App registration and consent

function Initialize-MDOP2App {
    <#
    .SYNOPSIS
    Makes sure the tool's app registration, its service principal and the admin-consented
    Mail.ReadWrite application permission exist. Safe to run every time.
    #>

    [CmdletBinding()]
    param([Parameter(Mandatory)][string]$DisplayName)

    $session = Get-MDOP2Session
    $changes = [Collections.Generic.List[string]]::new()

    $graphSp = Invoke-MDOP2DelegatedGraph -Method GET -Path "/servicePrincipals(appId='$script:GraphAppId')?`$select=id,appRoles"
    $role = @($graphSp.appRoles | Where-Object { $_.value -eq 'Mail.ReadWrite' -and $_.allowedMemberTypes -contains 'Application' })
    if ($role.Count -ne 1) { throw 'Could not find the Mail.ReadWrite application role on Microsoft Graph.' }
    $roleId = $role[0].id

    $filter = [uri]::EscapeDataString("displayName eq $(ConvertTo-MDOP2ODataLiteral $DisplayName)")
    $apps = @((Invoke-MDOP2DelegatedGraph -Method GET -Path "/applications?`$filter=$filter&`$select=id,appId,displayName").value)
    if ($apps.Count -gt 1) {
        throw "Found $($apps.Count) app registrations named '$DisplayName'. Remove the duplicates or use -AppDisplayName."
    }
    if ($apps.Count -eq 1) {
        $app = $apps[0]
    }
    else {
        $app = Invoke-MDOP2DelegatedGraph -Method POST -Path '/applications' -Body @{
            displayName            = $DisplayName
            signInAudience         = 'AzureADMyOrg'
            requiredResourceAccess = @(@{
                resourceAppId  = $script:GraphAppId
                resourceAccess = @(@{ id = $roleId; type = 'Role' })
            })
        }
        $changes.Add('Created app registration')
    }

    $spFilter = [uri]::EscapeDataString("appId eq $(ConvertTo-MDOP2ODataLiteral $app.appId)")
    $sps = @((Invoke-MDOP2DelegatedGraph -Method GET -Path "/servicePrincipals?`$filter=$spFilter&`$select=id,appId").value)
    if ($sps.Count -ge 1) {
        $sp = $sps[0]
    }
    else {
        $sp = Invoke-MDOP2DelegatedGraph -Method POST -Path '/servicePrincipals' -Body @{ appId = $app.appId } -RetryOnReplication
        $changes.Add('Created service principal')
    }

    $assignments = @((Invoke-MDOP2DelegatedGraph -Method GET -Path "/servicePrincipals/$($sp.id)/appRoleAssignments" -RetryOnReplication).value)
    $hasRole = @($assignments | Where-Object { $_.resourceId -eq $graphSp.id -and $_.appRoleId -eq $roleId }).Count -gt 0
    if (-not $hasRole) {
        Invoke-MDOP2DelegatedGraph -Method POST -Path "/servicePrincipals/$($graphSp.id)/appRoleAssignedTo" -RetryOnReplication -Body @{
            principalId = $sp.id
            resourceId  = $graphSp.id
            appRoleId   = $roleId
        } | Out-Null
        $changes.Add('Granted admin consent for Mail.ReadWrite (application)')
    }

    $session.AppId = $app.appId
    $session.AppObjectId = $app.id
    [pscustomobject]@{
        AppId              = $app.appId
        AppObjectId        = $app.id
        ServicePrincipalId = $sp.id
        Changes            = $changes.ToArray()
    }
}

#endregion

#region Just-in-time secret

function Remove-MDOP2StaleJitSecret {
    <#
    .SYNOPSIS
    Removes secrets left behind by earlier sessions that did not shut down cleanly.
    #>

    [CmdletBinding()]
    param()
    $session = Get-MDOP2Session
    $app = Invoke-MDOP2DelegatedGraph -Method GET -Path "/applications/$($session.AppObjectId)?`$select=passwordCredentials"
    $stale = Select-MDOP2JitCredential -PasswordCredentials @($app.passwordCredentials)
    foreach ($cred in $stale) {
        if ($cred.keyId -eq $session.SecretKeyId) { continue }
        Invoke-MDOP2DelegatedGraph -Method POST -Path "/applications/$($session.AppObjectId)/removePassword" -Body @{ keyId = $cred.keyId } | Out-Null
        [pscustomobject]@{ KeyId = $cred.keyId; DisplayName = $cred.displayName }
    }
}

function New-MDOP2JitSecret {
    [CmdletBinding()]
    param([ValidateRange(1, 8)][int]$LifetimeHours = 2)
    $session = Get-MDOP2Session
    $end = [datetime]::UtcNow.AddHours($LifetimeHours)
    $result = Invoke-MDOP2DelegatedGraph -Method POST -Path "/applications/$($session.AppObjectId)/addPassword" -Body @{
        passwordCredential = @{
            displayName = New-MDOP2JitSecretName
            endDateTime = ConvertTo-MDOP2IsoDate $end
        }
    }
    $session.SecretKeyId = $result.keyId
    $session.Secret = ConvertTo-SecureString -String $result.secretText -AsPlainText -Force
    $result.secretText = $null
    [pscustomobject]@{ KeyId = $result.keyId; DisplayName = $result.displayName; Expires = $end }
}

function Remove-MDOP2JitSecret {
    [CmdletBinding()]
    param()
    if (-not $script:Session -or -not $script:Session.SecretKeyId) { return $false }
    $session = $script:Session
    try {
        Invoke-MDOP2DelegatedGraph -Method POST -Path "/applications/$($session.AppObjectId)/removePassword" -Body @{ keyId = $session.SecretKeyId } | Out-Null
        return $true
    }
    finally {
        $session.SecretKeyId = $null
        if ($session.Secret) { $session.Secret.Dispose() }
        $session.Secret = $null
        $session.Token = $null
        $session.TokenExpiry = [datetime]::MinValue
    }
}

#endregion

#region App-only Graph

function Get-MDOP2AppToken {
    <#
    .SYNOPSIS
    Returns an app-only Graph token from the client credentials flow. Kept in memory and refreshed 5 minutes before expiry.
    #>

    [CmdletBinding()]
    param([int]$SecretPropagationTimeoutSeconds = 120, [int]$MaxAttempts = 4)
    $session = Get-MDOP2Session
    if ($session.Token -and [datetime]::UtcNow -lt $session.TokenExpiry) { return $session.Token }
    if (-not $session.Secret) { throw 'No JIT secret in this session.' }

    $uri = "https://login.microsoftonline.com/$($session.TenantId)/oauth2/v2.0/token"
    $deadline = [datetime]::UtcNow.AddSeconds($SecretPropagationTimeoutSeconds)
    $failures = 0
    for ($attempt = 1; ; $attempt++) {
        $body = @{
            client_id     = $session.AppId
            client_secret = [Net.NetworkCredential]::new('', $session.Secret).Password
            scope         = 'https://graph.microsoft.com/.default'
            grant_type    = 'client_credentials'
        }
        $resp = Invoke-WebRequest -Method Post -Uri $uri -Body $body -ContentType 'application/x-www-form-urlencoded' -SkipHttpErrorCheck
        $body = $null
        $status = [int]$resp.StatusCode
        # An error page from a proxy is not JSON and must still end in a clear error
        $json = $null
        try { $json = $resp.Content | ConvertFrom-Json } catch { Write-Verbose "Token response with HTTP $status is not JSON." }
        if ($json -isnot [Management.Automation.PSCustomObject]) { $json = [pscustomobject]@{} }
        if ($status -eq 200 -and $json.PSObject.Properties['access_token']) {
            $session.Token = $json.access_token
            $session.TokenExpiry = [datetime]::UtcNow.AddSeconds([int]$json.expires_in - 300)
            return $session.Token
        }
        # A brand-new secret is not always accepted straight away (AADSTS7000215). Wait and retry.
        $codes = if ($json.PSObject.Properties['error_codes']) { @($json.error_codes) } else { @() }
        if ($codes -contains 7000215 -and [datetime]::UtcNow -lt $deadline) {
            $delay = Get-MDOP2RetryDelay -Attempt $attempt -MaxSeconds 20
            Write-Verbose "New secret not accepted yet. Retrying in $delay s."
            Start-Sleep -Seconds $delay
            continue
        }
        if (Test-MDOP2RetryableStatus -StatusCode $status) {
            $failures++
            if ($failures -lt $MaxAttempts) {
                $delay = Get-MDOP2RetryDelay -Attempt $failures -RetryAfter (Get-MDOP2RetryAfter -Response $resp) -MaxSeconds 30
                Write-Verbose "Token request returned $status. Retrying in $delay s."
                Start-Sleep -Seconds $delay
                continue
            }
        }
        $desc = if ($json.PSObject.Properties['error_description']) { ([string]$json.error_description -split "`r?`n")[0] } else { "HTTP $status" }
        throw "App-only token request failed: $desc"
    }
}

function Invoke-MDOP2AppGraph {
    <#
    .SYNOPSIS
    Calls Microsoft Graph with the app-only token. Retries 429/5xx with Retry-After or backoff and follows paging when -All is set.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][ValidateSet('GET', 'POST', 'PATCH', 'DELETE')][string]$Method,
        [Parameter(Mandatory)][string]$Path,
        [object]$Body,
        [switch]$All,
        [switch]$Raw,
        [int]$MaxAttempts = 6
    )
    $uri = $Path
    $items = [Collections.Generic.List[object]]::new()
    while ($uri) {
        $resp = Invoke-MDOP2GraphRequest -Method $Method -Path $uri -Body $Body -MaxAttempts $MaxAttempts
        if ($Raw) { return $resp }
        if (-not $resp.Content) { return $null }
        $json = $resp.Content | ConvertFrom-Json
        if (-not $All) { return $json }
        if ($json.PSObject.Properties['value']) { foreach ($v in $json.value) { $items.Add($v) } }
        $uri = if ($json.PSObject.Properties['@odata.nextLink']) { $json.'@odata.nextLink' } else { $null }
        $Body = $null
    }
    $items.ToArray()
}

function Invoke-MDOP2AppGraphBatch {
    <#
    .SYNOPSIS
    Sends GET requests through Graph JSON batching, 20 per batch and one batch at a time. Graph runs at most four requests
    of a batch at once, which stays within the Outlook limit of four concurrent requests per mailbox. Throttled and failed
    requests (429/5xx) are retried after the longest Retry-After. With -All, paged collections are followed.
    Returns a hashtable keyed by request ID; each result has Status, Value (collection items), Body, ErrorCode and Error.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)][AllowEmptyCollection()][object[]]$Request,
        [switch]$All,
        [int]$MaxAttempts = 6
    )
    $results = @{}
    $attempts = @{}
    $queue = [Collections.Generic.List[object]]::new()
    foreach ($r in $Request) { $queue.Add($r) }
    while ($queue.Count) {
        $take = [Math]::Min(20, $queue.Count)
        $batch = $queue.GetRange(0, $take)
        $queue.RemoveRange(0, $take)
        $responses = @{}
        try {
            $body = @{ requests = @($batch | ForEach-Object { @{ id = [string]$_.Id; method = 'GET'; url = [string]$_.Url } }) }
            $resp = Invoke-MDOP2AppGraph -Method POST -Path '/$batch' -Body $body -MaxAttempts $MaxAttempts
            if ($resp -and $resp.PSObject.Properties['responses']) {
                foreach ($x in @($resp.responses)) { $responses[[string]$x.id] = $x }
            }
        }
        catch {
            $sc = [int]($_.Exception.Data['StatusCode'] ?? 0)
            foreach ($r in $batch) {
                $results[[string]$r.Id] = [pscustomobject]@{ Status = $sc; Value = @(); Body = $null; ErrorCode = $_.Exception.Data['ErrorCode']; Error = $_.Exception.Message }
            }
            continue
        }
        $retry = [Collections.Generic.List[object]]::new()
        $wait = 0
        foreach ($r in $batch) {
            $id = [string]$r.Id
            $x = $responses[$id]
            if (-not $x) {
                $results[$id] = [pscustomobject]@{ Status = 0; Value = @(); Body = $null; ErrorCode = $null; Error = 'Graph batch returned no response for this request.' }
                continue
            }
            $status = [int]$x.status
            $b = if ($x.PSObject.Properties['body']) { $x.body } else { $null }
            if ($status -ge 200 -and $status -lt 300) {
                $value = @()
                $pageError = $null
                if ($All -and $b -and $b.PSObject.Properties['value']) {
                    $items = [Collections.Generic.List[object]]::new()
                    foreach ($v in @($b.value)) { $items.Add($v) }
                    if ($b.PSObject.Properties['@odata.nextLink'] -and $b.'@odata.nextLink') {
                        try { foreach ($v in @(Invoke-MDOP2AppGraph -Method GET -Path ([string]$b.'@odata.nextLink') -All)) { $items.Add($v) } }
                        catch { $pageError = $_ }
                    }
                    $value = $items.ToArray()
                }
                $results[$id] = if ($pageError) {
                    [pscustomobject]@{ Status = [int]($pageError.Exception.Data['StatusCode'] ?? 0); Value = @(); Body = $null
                        ErrorCode = $pageError.Exception.Data['ErrorCode']; Error = $pageError.Exception.Message }
                }
                else { [pscustomobject]@{ Status = $status; Value = $value; Body = $b; ErrorCode = $null; Error = $null } }
                continue
            }
            $n = [int]($attempts[$id] ?? 0) + 1
            if ((Test-MDOP2RetryableStatus -StatusCode $status) -and $n -lt $MaxAttempts) {
                $attempts[$id] = $n
                $ra = $null
                if ($x.PSObject.Properties['headers'] -and $x.headers -and $x.headers.PSObject.Properties['Retry-After']) { $ra = [string]$x.headers.'Retry-After' }
                $wait = [Math]::Max($wait, (Get-MDOP2RetryDelay -Attempt $n -RetryAfter $ra))
                $retry.Add($r)
                continue
            }
            $code = $null
            $message = ''
            if ($b -and $b.PSObject.Properties['error'] -and $b.error) {
                if ($b.error.PSObject.Properties['code']) { $code = [string]$b.error.code }
                if ($b.error.PSObject.Properties['message']) { $message = [string]$b.error.message }
            }
            $results[$id] = [pscustomobject]@{ Status = $status; Value = @(); Body = $b; ErrorCode = $code; Error = "Graph GET failed with HTTP ${status}: $code $message" }
        }
        if ($retry.Count) {
            Write-Verbose "Graph batch: $($retry.Count) request(s) throttled or failed. Retrying in $wait s."
            Start-Sleep -Seconds $wait
            $queue.InsertRange(0, $retry)
        }
    }
    $results
}

#endregion

Export-ModuleMember -Function New-MDOP2JitSecretName, Select-MDOP2JitCredential, Test-MDOP2TokenHasRole,
    ConvertTo-MDOP2Base64Url, New-MDOP2PkcePair, New-MDOP2AuthorizeUrl, Read-MDOP2AuthCallback, Get-MDOP2MissingScope,
    Connect-MDOP2Admin, Disconnect-MDOP2Admin, Get-MDOP2Session,
    Initialize-MDOP2App, Remove-MDOP2StaleJitSecret, New-MDOP2JitSecret, Remove-MDOP2JitSecret,
    Get-MDOP2AppToken, Invoke-MDOP2AppGraph, Invoke-MDOP2AppGraphBatch