OneDrive offline Backup with PowerShell and GRAPH API

As a business owner, you will eventually have to deal with Backup and Security. So today, we are taking care of File Backup.

All our files are backed up to OneDrive, so why do we need a Backup? All files are in the Cloud.
Also, all Services we use are Cloud services, so why should we back up anything?

Cause those Cloud Services might fail.
When we are honest, the Cloud is only someone else’s computer, so there can be an attack, an outage, or Data loss.
We can also experience some breaches, so the Cloud file gets encrypted. Now?

That is why I recently bought a UNIFI Storage for our offline Backup, and our first step was to save OneDrive to that Storage.

So, the easiest way would be to have a copy job on my computer, where OneDrive is located, and copy all the files to the Storage. But for this, I wouldn’t write a Blog Post; instead, I would download all the files to your computer first, then to the Storage. That is not a very smart solution.

So, I was trying to write a PowerShell Script that downloads Files directly via API to the offline Storage.

So here we are, Thanks to my colleague Ahmed, who already had a Function to renew Graph API Access token: uzejnovicahmed (Uzejnovic Ahmed)

Preperations

So first we need Access to our OneDrive via the GRAPH API, and for this we need an App Registration with the following Permissions.

  • Files.ReadWrite.All ( for automation, I would recommend using Application Permission)
#OneDrive GRAPH API Details
$clientID_OneDrive = "your Client ID"
$Clientsecret_OneDrive = "your Secret"
$tenantID = "Tenant ID"

Next, depending on your OneDrive Size and your bandwidth, the Backup will take some time, so we have to make sure our Access Token is renewed when it expires. I have to thank Ahmed for this part.


#region functions
function Request-AccessToken {
    [CmdletBinding()]
    param (
        [Parameter(Mandatory = $true)] [string]$TenantName,
        [Parameter(Mandatory = $true)] [string]$ClientId,
        [Parameter(Mandatory = $true)] [string]$ClientSecret
    )

    $tokenBody = @{
        Grant_Type    = 'client_credentials'
        Scope         = 'https://graph.microsoft.com/.default'
        Client_Id     = $ClientId
        Client_Secret = $ClientSecret
    }

    Write-Debug "Requesting a new access token from Microsoft Identity Platform..."

    try {
        $tokenResponse = Invoke-RestMethod -Uri "https://login.microsoftonline.com/$TenantName/oauth2/v2.0/token" -Method POST -Body $tokenBody -ErrorAction Stop
        $global:accessToken = $tokenResponse.access_token
        $global:tokenExpiresAt = (Get-Date).AddSeconds($tokenResponse.expires_in)
        $global:headers = @{
            "Authorization" = "Bearer $($global:accessToken)"
            "Content-type"  = "application/json"
        }
    }
    catch {
        Write-Output "Error generating access token: $($_.Exception.Message)"
        Write-Output "Exception Details: $($_.Exception)"
        return $null
    }
    Write-Output "Successfully generated authentication token"
    return $tokenResponse
}
# Function to renew the access token when close to expiration
function Renew-AccessToken {
    param (
        [string]$TenantName,
        [string]$ClientId,
        [string]$ClientSecret
    )

    Write-Debug "Attempting to renew the access token..."
    try {
        $tokenResponse = Request-AccessToken -TenantName $TenantName -ClientId $ClientId -ClientSecret $ClientSecret

        if ($null -ne $tokenResponse) {
            # Update the global access token and expiration time
            $global:accessToken = $tokenResponse.access_token
            $global:tokenExpiresAt = (Get-Date).AddSeconds($tokenResponse.expires_in)
            Write-Output "Token renewed successfully. New expiration time: $global:tokenExpiresAt"
        }
        else {
            Write-Output "Failed to renew the access token. Response was null."
        }
    }
    catch {
        Write-Output "Error renewing the access token: $($_.Exception.Message)"
    }
}
function Check-TokenExpiration {
    try {
        Write-Host "Checking token expiration at: $(Get-Date)..." 
        # Check if the token is about to expire (1 minute before expiration)
        if ((Get-Date) -ge $global:tokenExpiresAt.AddMinutes(-$minutesbeforetokenexpires)) {
            Write-Host "Access token is expired or close to expiration. Renewing the token..." -ForegroundColor Orange
            Renew-AccessToken -TenantName $tenantID -ClientId $clientID_OneDrive -ClientSecret $Clientsecret_OneDrive
        }
        else {
            Write-Host "Access token is still valid. Expires at: $global:tokenExpiresAt" -ForegroundColor Green
        }
    }
    catch {
        Write-Host "Error in Check-TokenExpiration function: $($_.Exception.Message)" -ForegroundColor Red
    }
}

#endregion functions


# Define global variables for the access token and expiration time
$global:accessToken = $null
$global:tokenExpiresAt = [datetime]::MinValue
$global:headers = $null
$tokencheckinterval = 300000  # 300 seconds (300000 milliseconds) -> Can be bigger in production.
$minutesbeforetokenexpires = 6 # Set how many minutes before token expiration the token should be renewed

# Request the initial token
$tokenResponse = Request-AccessToken -TenantName $tenantID -ClientId $clientID_OneDrive -ClientSecret $Clientsecret_OneDrive

#This is the Interval the  Token Check Takes place ! 
$timer = New-Object Timers.Timer
$timer.Interval = $tokencheckinterval
$Aktion = { Check-TokenExpiration }            
            
# Register the event handler to check token expiration
try {
    $timerEvent = Register-ObjectEvent -InputObject $timer -EventName Elapsed -SourceIdentifier "TokenCheck" -Action $Aktion
    Write-Output "Event registered successfully."
}
catch {
    Write-Output "Failed to register the timer event: $($_.Exception.Message)"
}

# Start the timer and verify that it is running
$timer.Start()
if ($timer.Enabled) {
    Write-Output "Timer started successfully. Access token will be checked for renewal every $($timer.Interval/1000) seconds."
}
else {
    Write-Output "Failed to start the timer."
}

Now we can learn about the Basic Settings and our OneDrive Details. This Example Script is limited to my personal OneDrive, but of course, it can be adapted to save others and shared OneDrives, too.

So we are configuring the Destination and the OneDrive we want to back up. replace the “*yourUPN*”

#Destionation Folder
$Dest = "Y:\OneDrive - Michael Seidl"

#OneDrive Details
$Drive = Invoke-RestMethod -Uri "https://graph.microsoft.com/v1.0/users/*yourUPN*/drive" -Method GET -Headers $global:headers
$Child = Invoke-RestMethod -Uri "https://graph.microsoft.com/v1.0/users/*yourUPN*/drives/$($Drive.id)/root/children" -Method GET -Headers $global:headers

Now we are coming to the tricky part, which took me some time. We got the first root content, like Files and Folders.

Doing a foreach and checking if it is a File or Folder.

If Folder, let’s check if it exists and create it.

If a File, check if it does not exist, or when it was last changed. In some cases, download the file.

Everything should be in a single loop because I do not know how many subfolders I have. So this was the first one I worked with: calling a Function within the Function itself.

Luckily, it works. On Top, we are creating a Job for each download, so we can go ahead and check the following files without waiting for the download to be finished.



Function Download-Item {
    param (
        [object]$ItemObject,
        [hashtable]$Headers
    )
    $ItemID = $ItemObject.id

    if ($ItemObject.folder -ne $null) {
        #Folder
        $ParentFolder = "$Dest\$($ItemObject.parentReference.path.Split("root:/")[1])"
        if (-not (Test-Path -Path $ParentFolder)) {
            New-Item -ItemType Directory -Path $ParentFolder -Force | Out-Null
        }
        $SubItemObject = Invoke-RestMethod -Uri "https://graph.microsoft.com/v1.0/users/michael.seidl@au2mator.com/drives/$($Drive.id)/items/$($ItemID)/children" -Method GET -Headers $global:headers
        foreach ($item in $SubItemObject.value | Sort-Object -Property size -Descending) {
            Download-Item -ItemObject $item -Headers $global:headers
        }           
    }
    elseif ($ItemObject.file -ne $null) {
        #File
        $ParentFolder = "$Dest\$($ItemObject.parentReference.path.Split("root:/")[1])"
        $ParentFolder = $ParentFolder.Replace("/$($ItemObject.name)", '')
        if (-not (Test-Path -Path $ParentFolder)) {
            New-Item -ItemType Directory -Path $ParentFolder -Force | Out-Null
        }

        $ParentFolder = "$ParentFolder\$($ItemObject.name)"
        $Download = $false
        try {
            $LocalFile = Get-Item -Path $ParentFolder -ErrorAction Stop
            if ($LocalFile -and ($LocalFile.LastWriteTime -lt $ItemObject.lastModifiedDateTime)) {
                $Download = $true
            }
        }
        catch {
            $Download = $true
        }

        if ($Download) {
            write-Output "Downloading File: $($ItemObject.name) to $ParentFolder"
            Start-Job  -ScriptBlock {
                param($ItemID, $Headers, $ParentFolder, $Drive)
                Invoke-RestMethod -Uri "https://graph.microsoft.com/v1.0/users/michael.seidl@au2mator.com/drives/$($Drive.id)/items/$($ItemID)/content" -Method GET -Headers $Headers -OutFile $ParentFolder
            } -ArgumentList $ItemID, $Headers, $ParentFolder, $Drive | Out-Null
        }
    }
    else {  
    }    
}


#Main part
foreach ($item in $Child.value | Sort-Object -Property size -Descending) {
    Download-Item -ItemObject $item  -Headers $headers 
}

At the end, we are stopping the Timer Job.


# Stop the timer and unregister the event when done
Write-Output "Stopping the timer and unregistering the event..."
$timer.Stop()
Unregister-Event -SourceIdentifier "TokenCheck"
$timer.Dispose()
Write-Output "Timer stopped. Final Access Token Expiration: $global:tokenExpiresAt"

So, this might not yet be perfect, but it helps me to do my Backups so that it might help you as well.

This script took me some time; if this were a paid customer project, it would cost around 2.000 euros, and you get this for free. You can show your support by being a GitHub Sponsor: Sponsor @Seidlm on GitHub Sponsors

The complete and updated script is here in my GitHub Repo: Seidlm/Microsoft-Graph-API-Examples

Michael Seidl, aka Techguy
au2mate everything

Leave a Comment

Your email address will not be published. Required fields are marked *

*