Public/Connect-GroupManager.ps1

function Connect-GroupManager {
    <#
    .SYNOPSIS
        Connects to Microsoft Graph for GroupManager operations.
    .DESCRIPTION
        Establishes a connection to Microsoft Graph with the required scopes.
        Uses custom app registration if configured via Set-GroupManagerAuth.
    .PARAMETER Force
        Disconnect and reconnect even if already connected.
    .EXAMPLE
        Connect-GroupManager
    .EXAMPLE
        Connect-GroupManager -Force
    #>

    [CmdletBinding()]
    param(
        [switch]$Force
    )

    # Disable WAM - use browser-based auth for cross-platform support (Windows/macOS/Linux)
    $env:AZURE_CLIENT_DISABLE_WAM = "true"

    $customClientId = if ($env:GROUPMANAGER_CLIENTID) { $env:GROUPMANAGER_CLIENTID } else { [System.Environment]::GetEnvironmentVariable('GROUPMANAGER_CLIENTID', 'User') }
    $customTenantId = if ($env:GROUPMANAGER_TENANTID) { $env:GROUPMANAGER_TENANTID } else { [System.Environment]::GetEnvironmentVariable('GROUPMANAGER_TENANTID', 'User') }

    $GraphContext = Get-MgContext

    if ($Force -and $GraphContext) {
        Disconnect-MgGraph -ErrorAction SilentlyContinue | Out-Null
        $GraphContext = $null
    }

    if (-not $GraphContext) {
        Write-Host ""
        Write-Host " Connecting to Microsoft Graph..." -ForegroundColor Cyan

        if ($customClientId -and $customTenantId) {
            Write-Host " Using custom app registration" -ForegroundColor DarkGray
            
            # Use custom OAuth flow with branded success page
            $token = Get-GroupManagerToken -ClientId $customClientId -TenantId $customTenantId
            if ($token) {
                Connect-MgGraph -AccessToken ($token | ConvertTo-SecureString -AsPlainText -Force) -NoWelcome -WarningAction SilentlyContinue
            }
            else {
                throw "Failed to acquire authentication token"
            }
        }
        else {
            Connect-MgGraph -Scopes "GroupMember.ReadWrite.All", "User.Read.All" -NoWelcome -WarningAction SilentlyContinue
        }
    }
    else {
        Write-Host " Already connected as $($GraphContext.Account)" -ForegroundColor Green
    }
}

function Get-GroupManagerToken {
    <#
    .SYNOPSIS
        Acquires an access token using OAuth authorization code flow with custom success page.
    #>

    [CmdletBinding()]
    param(
        [Parameter(Mandatory)]
        [string]$ClientId,
        
        [Parameter(Mandatory)]
        [string]$TenantId
    )

    $scopes = "https://graph.microsoft.com/.default offline_access openid profile"
    $port = Get-Random -Minimum 49152 -Maximum 65535
    $redirectUri = "http://localhost:$port"
    $state = [Guid]::NewGuid().ToString()
    
    # PKCE
    $codeVerifier = -join ((65..90) + (97..122) + (48..57) + 45, 46, 95, 126 | Get-Random -Count 64 | ForEach-Object { [char]$_ })
    $sha256 = [System.Security.Cryptography.SHA256]::Create()
    $hash = $sha256.ComputeHash([System.Text.Encoding]::UTF8.GetBytes($codeVerifier))
    $codeChallenge = [Convert]::ToBase64String($hash).TrimEnd('=').Replace('+', '-').Replace('/', '_')

    # Build auth URL
    $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"

    # Start listener
    $listener = [System.Net.HttpListener]::new()
    $listener.Prefixes.Add("$redirectUri/")
    $listener.Start()

    # Open browser
    Start-Process $authUrl

    # Wait for callback
    $context = $listener.GetContext()
    $request = $context.Request
    $response = $context.Response

    # Parse the authorization code
    $query = [System.Web.HttpUtility]::ParseQueryString($request.Url.Query)
    $code = $query["code"]
    $returnedState = $query["state"]
    $error = $query["error"]

    # Custom success/error page
    $moduleVersion = (Get-Module GroupManager -ErrorAction SilentlyContinue | Select-Object -First 1).Version
    if (-not $moduleVersion) { $moduleVersion = "1.0.0" } else { $moduleVersion = $moduleVersion.ToString() }

    if ($error -or $returnedState -ne $state) {
        $html = Get-AuthErrorPage -ErrorMessage ($query["error_description"] ?? "Authentication failed") -Version $moduleVersion
        $buffer = [System.Text.Encoding]::UTF8.GetBytes($html)
        $response.ContentLength64 = $buffer.Length
        $response.ContentType = "text/html"
        $response.OutputStream.Write($buffer, 0, $buffer.Length)
        $response.OutputStream.Close()
        $listener.Stop()
        return $null
    }

    $html = Get-AuthSuccessPage -Version $moduleVersion
    $buffer = [System.Text.Encoding]::UTF8.GetBytes($html)
    $response.ContentLength64 = $buffer.Length
    $response.ContentType = "text/html"
    $response.OutputStream.Write($buffer, 0, $buffer.Length)
    $response.OutputStream.Close()
    $listener.Stop()

    # Exchange code for token
    $tokenUrl = "https://login.microsoftonline.com/$TenantId/oauth2/v2.0/token"
    $body = @{
        client_id     = $ClientId
        scope         = $scopes
        code          = $code
        redirect_uri  = $redirectUri
        grant_type    = "authorization_code"
        code_verifier = $codeVerifier
    }

    try {
        $tokenResponse = Invoke-RestMethod -Uri $tokenUrl -Method Post -Body $body -ContentType "application/x-www-form-urlencoded"
        return $tokenResponse.access_token
    }
    catch {
        Write-Error "Token exchange failed: $_"
        return $null
    }
}

function Get-AuthSuccessPage {
    param([string]$Version)
    
    @"
<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>GroupManager - Authentication Successful</title>
    <style>
        * { margin: 0; padding: 0; box-sizing: border-box; }
        body {
            min-height: 100vh;
            display: flex;
            align-items: center;
            justify-content: center;
            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
            background: linear-gradient(135deg, #0078D4 0%, #00BCF2 100%);
            color: white;
            text-align: center;
            padding: 20px;
        }
        .container { max-width: 500px; }
        .logo {
            font-size: 0.85rem;
            letter-spacing: 0.3em;
            opacity: 0.8;
            margin-bottom: 2rem;
        }
        .checkmark {
            font-size: 4rem;
            margin-bottom: 1rem;
        }
        h1 {
            font-size: 1.75rem;
            font-weight: 500;
            margin-bottom: 0.75rem;
        }
        .subtitle {
            opacity: 0.9;
            font-size: 1rem;
            margin-bottom: 2rem;
        }
        .author {
            font-size: 0.75rem;
            opacity: 0.6;
            margin-top: 2rem;
        }
    </style>
</head>
<body>
    <div class="container">
        <div class="logo">[ G R O U P M A N A G E R ] v$Version</div>
        <div class="checkmark">✓</div>
        <h1>Authentication Successful</h1>
        <p class="subtitle">You can close this window and return to PowerShell.</p>
        <p class="author">by Mark Orr</p>
    </div>
</body>
</html>
"@

}

function Get-AuthErrorPage {
    param(
        [string]$ErrorMessage,
        [string]$Version
    )
    
    @"
<!DOCTYPE html>
<html>
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>GroupManager - Authentication Failed</title>
    <style>
        * { margin: 0; padding: 0; box-sizing: border-box; }
        body {
            min-height: 100vh;
            display: flex;
            align-items: center;
            justify-content: center;
            font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
            background: linear-gradient(135deg, #e66767 0%, #a24b76 100%);
            color: white;
            text-align: center;
            padding: 20px;
        }
        .container { max-width: 500px; }
        .logo {
            font-size: 0.85rem;
            letter-spacing: 0.3em;
            opacity: 0.8;
            margin-bottom: 2rem;
        }
        .icon {
            font-size: 4rem;
            margin-bottom: 1rem;
        }
        h1 {
            font-size: 1.75rem;
            font-weight: 500;
            margin-bottom: 0.75rem;
        }
        .error-message {
            opacity: 0.9;
            font-size: 0.95rem;
            background: rgba(0,0,0,0.2);
            padding: 1rem;
            border-radius: 8px;
            margin-top: 1rem;
        }
    </style>
</head>
<body>
    <div class="container">
        <div class="logo">[ G R O U P M A N A G E R ] v$Version</div>
        <div class="icon">✕</div>
        <h1>Authentication Failed</h1>
        <p class="error-message">$ErrorMessage</p>
    </div>
</body>
</html>
"@

}