#Requires -Version 5.1 <# .SYNOPSIS OOBE Technician Toolkit bootstrap. Run during Windows OOBE via Shift+F10. .DESCRIPTION Authenticates the technician via an interactive login popup (MSAL.PS), falling back to device-code flow if a popup cannot be shown. Verifies group membership, downloads the signed OOBEToolkit module, and launches the technician menu. .EXAMPLE irm https://.blob.core.windows.net/public/bootstrap.ps1 | iex Or after custom domain is configured: irm https://agent.bjorn-it.com/bootstrap.ps1 | iex #> # ─── Configuration ──────────────────────────────────────────────────────────── # These values are filled in by scripts/deploy.ps1 after deployment. # Placeholders are replaced with real values; do not edit manually. $script:Config = @{ TenantId = '03568fbc-dd10-4253-bce0-cb7513443ce4' ClientId = 'd6c50040-4b8a-49ea-b465-e6cdaf045869' FunctionBaseUrl = 'https://func-oobe-dukyffthopawc.azurewebsites.net' LogPath = 'C:\ProgramData\BLOOBE\Logs' Scope = 'https://graph.microsoft.com/User.Read' } # ─── TLS / protocol hardening ───────────────────────────────────────────────── [Net.ServicePointManager]::SecurityProtocol = ` [Net.SecurityProtocolType]::Tls12 -bor [Net.SecurityProtocolType]::Tls13 # ─── Helpers ────────────────────────────────────────────────────────────────── function Write-Banner { $line = '=' * 52 Write-Host "`n$line" -ForegroundColor Cyan Write-Host ' OOBE Technician Toolkit v1.0' -ForegroundColor White Write-Host ' Bjorn Lunden IT' -ForegroundColor Gray Write-Host "$line`n" -ForegroundColor Cyan } function Ensure-LogDirectory { if (-not (Test-Path $script:Config.LogPath)) { New-Item -ItemType Directory -Path $script:Config.LogPath -Force | Out-Null } } function Write-Log { param([string]$Message, [string]$Level = 'INFO') Ensure-LogDirectory $ts = Get-Date -Format 'yyyy-MM-dd HH:mm:ss' $file = Join-Path $script:Config.LogPath "bootstrap-$(Get-Date -Format 'yyyy-MM-dd').log" "$ts [$Level] $Message" | Out-File -Append -FilePath $file -Encoding utf8 } function Test-NetworkConnectivity { Write-Host ' Checking network connectivity...' -NoNewline try { $null = Invoke-WebRequest -Uri 'https://login.microsoftonline.com' ` -UseBasicParsing -TimeoutSec 10 -ErrorAction Stop Write-Host ' OK' -ForegroundColor Green return $true } catch { Write-Host ' FAILED' -ForegroundColor Red Write-Host " Error: $_" -ForegroundColor Red return $false } } # ─── Interactive popup authentication via MSAL.PS ───────────────────────────── function Invoke-InteractiveAuth { if (-not (Get-Module -ListAvailable -Name 'MSAL.PS')) { Write-Host ' Installing MSAL.PS (first run only)...' -NoNewline Install-PackageProvider -Name NuGet -MinimumVersion 2.8.5.201 -Force -Scope CurrentUser -ErrorAction SilentlyContinue | Out-Null Set-PSRepository -Name PSGallery -InstallationPolicy Trusted -ErrorAction SilentlyContinue Install-Module -Name 'MSAL.PS' -Scope CurrentUser -Force -ErrorAction Stop | Out-Null Write-Host ' OK' -ForegroundColor Green } Import-Module MSAL.PS -ErrorAction Stop Write-Host ' Opening login popup...' -ForegroundColor Gray $token = Get-MsalToken ` -ClientId $script:Config.ClientId ` -TenantId $script:Config.TenantId ` -Scopes ($script:Config.Scope, 'openid', 'profile' -join ' ') ` -Interactive ` -ErrorAction Stop return @{ access_token = $token.AccessToken } } # ─── Device-code authentication fallback (pure PS 5.1, no modules) ──────────── function Invoke-DeviceCodeAuth { $tenantId = $script:Config.TenantId $clientId = $script:Config.ClientId $scope = $script:Config.Scope + ' openid profile offline_access' # Step 1: request device code Write-Host "`n Requesting authentication code..." -NoNewline try { $dcResponse = Invoke-RestMethod ` -Uri "https://login.microsoftonline.com/$tenantId/oauth2/v2.0/devicecode" ` -Method POST ` -ContentType 'application/x-www-form-urlencoded' ` -Body "client_id=$clientId&scope=$([Uri]::EscapeDataString($scope))" ` -ErrorAction Stop } catch { Write-Host ' FAILED' -ForegroundColor Red throw "Device code request failed: $_" } Write-Host ' OK' -ForegroundColor Green # Step 2: show instructions Write-Host "`n ┌─────────────────────────────────────────────────┐" -ForegroundColor Yellow Write-Host " │ Open on any device: $($dcResponse.verification_uri)" -ForegroundColor Yellow Write-Host " │ Enter code: $($dcResponse.user_code)" -ForegroundColor White Write-Host " └─────────────────────────────────────────────────┘`n" -ForegroundColor Yellow Write-Log "Device code shown to technician. Code: $($dcResponse.user_code)" # Step 3: poll for token $interval = [int]$dcResponse.interval $expiresIn = [int]$dcResponse.expires_in $deadline = (Get-Date).AddSeconds($expiresIn) $tokenBody = "client_id=$clientId&device_code=$($dcResponse.device_code)&grant_type=urn:ietf:params:oauth:grant-type:device_code" Write-Host ' Waiting for authentication' -NoNewline while ((Get-Date) -lt $deadline) { Start-Sleep -Seconds $interval Write-Host '.' -NoNewline try { $tokenResp = Invoke-RestMethod ` -Uri "https://login.microsoftonline.com/$tenantId/oauth2/v2.0/token" ` -Method POST ` -ContentType 'application/x-www-form-urlencoded' ` -Body $tokenBody ` -ErrorAction Stop Write-Host ' Authenticated!' -ForegroundColor Green return $tokenResp } catch { $errMsg = $_.ErrorDetails.Message | ConvertFrom-Json -ErrorAction SilentlyContinue if ($errMsg.error -eq 'authorization_pending') { continue } if ($errMsg.error -eq 'authorization_declined') { throw 'Authentication was declined.' } if ($errMsg.error -eq 'expired_token') { throw 'Authentication timed out. Please try again.' } throw "Authentication error: $($errMsg.error_description)" } } throw 'Authentication timed out. Please try again.' } # ─── Authorize with backend + get module SAS URL ────────────────────────────── function Invoke-BackendAuthorize { param([string]$AccessToken) Write-Host ' Verifying authorization...' -NoNewline try { $response = Invoke-RestMethod ` -Uri "$($script:Config.FunctionBaseUrl)/api/authorize" ` -Method POST ` -Headers @{ Authorization = "Bearer $AccessToken" } ` -ContentType 'application/json' ` -ErrorAction Stop Write-Host ' Authorized' -ForegroundColor Green Write-Log "Authorized: UPN=$($response.userPrincipalName)" return $response } catch { $code = if ($_.Exception.Response) { [int]$_.Exception.Response.StatusCode } else { 0 } $body = try { $_.ErrorDetails.Message } catch { '' } if ($code -eq 401) { throw 'Invalid or expired token. Please re-run bootstrap.' } if ($code -eq 403) { throw 'Access denied. You are not in the IT-OOBE-Technicians group.' } throw "Authorization failed (HTTP $code): $body" } } # ─── Download + verify module ───────────────────────────────────────────────── function Get-OOBEToolkitModule { param([string]$SasUrl, [string]$ExpectedHash) $tmpDir = Join-Path $env:TEMP 'OOBEToolkit' $zipPath = Join-Path $env:TEMP 'OOBEToolkit.zip' # Clean previous run if (Test-Path $zipPath) { Remove-Item $zipPath -Force } if (Test-Path $tmpDir) { Remove-Item $tmpDir -Recurse -Force } Write-Host ' Downloading OOBEToolkit module...' -NoNewline try { Invoke-WebRequest -Uri $SasUrl -OutFile $zipPath -UseBasicParsing -ErrorAction Stop Write-Host ' OK' -ForegroundColor Green } catch { throw "Module download failed: $_" } # Verify hash if ($ExpectedHash -and $ExpectedHash -ne 'PLACEHOLDER-UPDATE-AFTER-PUBLISH') { Write-Host ' Verifying module integrity...' -NoNewline $actualHash = (Get-FileHash -Path $zipPath -Algorithm SHA256).Hash if ($actualHash -ne $ExpectedHash.ToUpper()) { Remove-Item $zipPath -Force throw "Module hash mismatch! Expected: $ExpectedHash Got: $actualHash" } Write-Host ' OK' -ForegroundColor Green Write-Log "Module hash verified: $actualHash" } else { Write-Host ' [WARN] Module hash not configured — skipping integrity check' -ForegroundColor Yellow } # Expand New-Item -ItemType Directory -Path $tmpDir -Force | Out-Null Add-Type -AssemblyName System.IO.Compression.FileSystem [System.IO.Compression.ZipFile]::ExtractToDirectory($zipPath, $tmpDir) return (Join-Path $tmpDir 'OOBEToolkit') } # ─── Main ───────────────────────────────────────────────────────────────────── try { Write-Banner Ensure-LogDirectory Write-Log 'Bootstrap started' # Validate config is substituted if ($script:Config.ClientId -like 'PLACEHOLDER*') { throw 'App Registration not yet configured. Follow azure/setup-entraid.md and redeploy.' } if ($script:Config.FunctionBaseUrl -like 'PLACEHOLDER*') { throw 'bootstrap.ps1 has not been deployed yet. Run scripts/deploy.ps1 first.' } # Network check if (-not (Test-NetworkConnectivity)) { throw 'No network connectivity. Connect to the internet and try again.' } # Authenticate — popup first, device code as fallback Write-Host ' Step 1/3: Authenticate with your work account' $tokenResp = $null try { $tokenResp = Invoke-InteractiveAuth Write-Host ' Authenticated!' -ForegroundColor Green } catch { Write-Host " (popup unavailable: $_)" -ForegroundColor Yellow Write-Host ' Falling back to device code login...' Write-Log "Interactive auth failed, using device code: $_" -Level 'WARN' $tokenResp = Invoke-DeviceCodeAuth } $accessToken = $tokenResp.access_token # Authorize + get module SAS URL Write-Host "`n Step 2/3: Checking group membership" $authResp = Invoke-BackendAuthorize -AccessToken $accessToken # Download module Write-Host "`n Step 3/3: Loading toolkit" $modulePath = Get-OOBEToolkitModule ` -SasUrl $authResp.moduleUrl ` -ExpectedHash $authResp.moduleHash # Import and launch Import-Module $modulePath -Force -ErrorAction Stop Write-Log 'OOBEToolkit module loaded' # Pass session context to the module Invoke-OOBEMenu -AccessToken $accessToken ` -UserPrincipalName $authResp.userPrincipalName ` -FunctionBaseUrl $script:Config.FunctionBaseUrl } catch { Write-Host "`n ERROR: $_" -ForegroundColor Red Write-Log "FATAL: $_" -Level 'ERROR' Write-Host "`n Press any key to exit..." -ForegroundColor Gray $null = $Host.UI.RawUI.ReadKey('NoEcho,IncludeKeyDown') }