SharePoint Online AAD App OAuth

This post is a contribution from Vitaly Lyamin, an engineer with the SharePoint Developer Support team

Accessing SharePoint API’s has never been easier (SPOIDCRL cookie, ACS OAuth, AAD OAuth). Azure AD apps are quickly becoming the standard way of accessing O365 API’s in addition to other API’s. Below are some resources on registering apps and using libraries. Also, there’s a test script that walks through the entire authorization grant flow. The end goal with all OAuth-based authorization is to retrieve the access token to be used in the HTTP request Authorization header (Authorization: Bearer <access token>).

Native Client App
Native app registrations are primarily for devices and services where browser interaction is not needed. One of the biggest benefits is the non-interactive (active) authorization using credentials, Federated IDP assertion or similar.

Links /en-us/azure/active-directory/develop/active-directory-authentication-scenarios#native-application-to-web-api https://azure.microsoft.com/en-us/resources/samples/active-directory-dotnet-native-headless

Web App / API
Web app registrations are just as they sound – apps on the web. These apps typically use the authorization grant and refresh grant flows and are not intended for devices/services. Once authorized (some permissions scopes require admin consent), the access token is retrieved from the OAuth token endpoint using the authorization code.

Authorization URL
https://login.microsoftonline.com/common/oauth2/authorize?resource=\<RESOURCE>&client_id=>CLIENTID>&scope=<SCOPE>&redirect_uri=<REDIRECTURI>&response_type=code&prompt=admin_consent

Access Token URL
https://login.microsoftonline.com/common/oauth2/token

Link /en-us/azure/active-directory/develop/active-directory-authentication-scenarios#web-browser-to-web-application

Libraries
ADAL libraries are available in many different flavors and are quick and easy to implement. There primary purpose is to authorize the user/service to a resource (e.g. SharePoint REST API’s, Graph).

Link /en-us/azure/active-directory/develop/active-directory-authentication-libraries

Other Resources /en-us/azure/active-directory/develop/active-directory-integrating-applications https://msdn.microsoft.com/en-us/office/office365/howto/getting-started-Office-365-APIs

Test Script (Web App)

 <#
    .Synopsis
        Get access token for AAD web app.

    .Description
        Authorizes AAD app and retrieves access token using OAuth 2.0 and endpoints.
        Refreshes the token if within 5 minutes of expiration or, optionally forces refresh.
        Sets global variable ($Global:accessTokenResult) that can be used after the script runs.

    .Todo
        Add ability to handle refresh token input and access token retrieval without re-authorization.

    .Example 
        The following returns the access token result from AAD with admin consent authorization and caches the result.

        PS> .\aad_web.ps1 -Clientid "" -Clientsecret "" -Resource "https://TENANT.sharepoint.com" -Redirecturi "https://localhost:44385" -Scope "" -AdminConsent -Cache
    
    .Example 
        The following returns the access token result from AAD with admin consent authorization or refreshes the token.

        PS> .\aad_web.ps1 -Clientid "" -Clientsecret "" -Resource "https://TENANT.sharepoint.com" -Redirecturi "https://localhost:44385" -Scope "" -AdminConsent
    
    .Example 
        The following returns the access token result from AAD or from cache, forces refresh so the token is good for an hour and outputs to a file

        PS> .\aad_web.ps1 -Clientid "" -Clientsecret "" -Resource "https://TENANT.sharepoint.com" -Redirecturi "https://localhost:44385" -Scope "" -Refresh Force | Out-File c:\temp\token.txt

    .PARAMETER ClientId 
        The AAD App client id.
    .PARAMETER ClientSecret
        The AAD App client secret.    
    .PARAMETER RedirectUri
        The redirect uri configured for that app.
    .PARAMETER Resource
        The resource the app is attempting to access (i.e. https://TENANT.sharepoint.com)
    .PARAMETER Scope
        Permission scopes for the app (optional).
    .PARAMETER AdminConsent
        Will perform admin consent (optional).
    .PARAMETER Cache
        Cache the access token in the temp directory for subsequent retrieval (optional).
    .PARAMETER Refresh
        Options (Yes, No, Force). Will automatically enabling caching if "Yes" or "Force" are used.
        Yes: Refresh token if within 5 minutes of expiration if cached token found.
        No: Do not refresh and re-authorize.
        Force: Forfce a refresh if cached token found.

#>
[CmdletBinding()]
Param(
[Parameter(Mandatory=$true)]
[string]$ClientId,
[Parameter(Mandatory=$true)]
[string]$ClientSecret,
[Parameter(Mandatory=$true)]
[string]$RedirectUri,
[Parameter(Mandatory=$true)]
[string]$Resource,
[Parameter(Mandatory=$false)]
[string]$Scope,
[Parameter(Mandatory=$false)]
[switch]$AdminConsent,
[Parameter(Mandatory=$false)]
[switch]$Cache,
[Parameter(Mandatory=$false)]
[ValidateSet("Yes","No","Force")]
[ValidateNotNullOrEmpty()]
[string]$Refresh = "Yes"
)

Add-Type -AssemblyName System.Windows.Forms
Add-Type -AssemblyName System.Web

$isCache = $Cache.IsPresent
$isRefresh = (($Refresh -eq "Yes") -or ($Refresh -eq "Force"))
$refreshForce = $Refresh -eq "Force"

if ($isRefresh)
{
    $isCache = $true
}

# Don't edit variables below (unless there's a bug)
$clientSecretEncoded = [uri]::EscapeDataString($clientSecret)
$redirectUriEncoded = [uri]::EscapeDataString($redirectUri)
$resourceEncoded = [uri]::EscapeDataString($resource)
$accessTokenUrl = "https://login.microsoftonline.com/common/oauth2/token"
$cacheFilePath = [System.IO.Path]::Combine($env:TEMP, "aad_web_cache_$clientId.json")

$accessTokenResult = $null
$adminConsentText =""
if ($adminConsent)
{
    $adminConsentText = "&prompt=admin_consent"
}

$authorizationUrl = "https://login.microsoftonline.com/common/oauth2/authorize?resource=$resourceEncoded&client_id=$clientId&scope=$scope&redirect_uri=$redirectUriEncoded&response_type=code$adminConsentText"

function Invoke-OAuth()
{
    $Global:authorizationCode = $null

    $form = New-Object Windows.Forms.Form
    $form.FormBorderStyle = [Windows.Forms.FormBorderStyle]::FixedSingle
    $form.Width = 640
    $form.Height = 480
    $form.MaximizeBox = $false
    $form.MinimizeBox = $false

    $web = New-Object Windows.Forms.WebBrowser
    $form.Controls.Add($web)

    $web.Size = $form.ClientSize
    $web.DocumentText = "<html><body style='text-align:center;overflow:hidden;background-image:url(https://secure.aadcdn.microsoftonline-p.com/ests/2.1.6856.20/content/images/backgrounds/0.jpg?x=f5a9a9531b8f4bcc86eabb19472d15d5)'><h3 id='title'>Continue with current user or logout?</h3><div><input id='cancel' type='button' value='Continue' /></div><br /><div><input id='logout' type='button' value='Logout' /></div><h5 id='loading' style='display:none'>Working on it...</h5><script type='text/javascript'>var logout = document.getElementById('logout');var cancel = document.getElementById('cancel');function click(element){document.getElementById('title').style.display='none';document.getElementById('loading').style.display='block';logout.style.display='none';cancel.style.display='none';if (this.id === 'logout'){window.location = 'https://login.microsoftonline.com/common/oauth2/logout?post_logout_redirect_uri=' + encodeURIComponent('$authorizationUrl');}else{window.location = '$authorizationUrl';}}logout.onclick = click;cancel.onclick = click;</script></body></html>"

    $web.add_DocumentCompleted(
    {
        $uri = [uri]$redirectUri
        $queryString = [System.Web.HttpUtility]::ParseQueryString($_.url.Query)

        if($_.url.authority -eq $uri.authority)
        {
            $authorizationCode = $queryString["code"]
        
            if (![string]::IsNullOrEmpty($authorizationCode))
            {
                $form.DialogResult = "OK"
                $Global:authorizationCode = $authorizationCode
                $Global:authorizationCodeTime = [datetime]::Now
            }

            $form.close()
        }
    })

    $dialogResult = $form.ShowDialog()

    if($dialogResult -eq "OK")
    {
        $authorizationCode = $Global:authorizationCode
        $headers = @{"Accept" = "application/json;odata=verbose"}
        $body = "client_id=$clientId&client_secret=$clientSecretEncoded&redirect_uri=$redirectUriEncoded&grant_type=authorization_code&code=$authorizationCode"
    
        $accessTokenResult = Invoke-RestMethod -Uri $accessTokenUrl -Method POST -Body $body -Headers $headers
        $Global:accessTokenResult = $accessTokenResult
        $Global:accessTokenResultTime = [datetime]::Now
        $accessTokenResultText = (ConvertTo-Json $accessTokenResult)

        if ($isCache -and ![string]::IsNullOrEmpty($accessTokenResultText))
        {
            [void](Set-Content -Path $cacheFilePath -Value $accessTokenResultText)
        }

        Write-Output (ConvertTo-Json $accessTokenResultText)
    }

    $web.Dispose()
    $form.Dispose()
}

function Get-CachedAccessTokenResult()
{
    if ($isCache -and [System.IO.File]::Exists($cacheFilePath))
    {
        $accessTokenResultText = Get-Content -Raw $cacheFilePath
        if (![string]::IsNullOrEmpty($accessTokenResultText))
        {
            $accessTokenResult  = (ConvertFrom-Json $accessTokenResultText)
            if (![string]::IsNullOrEmpty($accessTokenResult.access_token))
            {
                $Global:accessTokenResult = $accessTokenResult

                return $accessTokenResult
            }
        }
    }

    return $null
}

function Invoke-Refresh()
{
    $refreshToken = $accessTokenResult.refresh_token
    $headers = @{"Accept" = "application/json;odata=verbose"}
    $body = "client_id=$clientId&client_secret=$clientSecretEncoded&resource=$resourceEncoded&grant_type=refresh_token&refresh_token=$refreshToken"
    $accessTokenResult2 = Invoke-RestMethod -Uri $accessTokenUrl -Method POST -Body $body -Headers $headers

    $accessTokenResult.scope = $accessTokenResult2.scope
    $accessTokenResult.expires_in = $accessTokenResult2.expires_in
    $accessTokenResult.ext_expires_in = $accessTokenResult2.ext_expires_in
    $accessTokenResult.expires_on = $accessTokenResult2.expires_on
    $accessTokenResult.not_before = $accessTokenResult2.not_before
    $accessTokenResult.resource = $accessTokenResult2.resource
    $accessTokenResult.access_token = $accessTokenResult2.access_token
    $accessTokenResult.refresh_token = $accessTokenResult2.refresh_token

    $Global:accessTokenResult = $accessTokenResult
    $Global:accessTokenResultTime = [datetime]::Now
    $accessTokenResultText = (ConvertTo-Json $accessTokenResult)

    if (![string]::IsNullOrEmpty($accessTokenResultText))
    {
        [void](Set-Content -Path $cacheFilePath -Value $accessTokenResultText)
    }

    Write-Output (ConvertTo-Json $accessTokenResultText)
}

$accessTokenResult = Get-CachedAccessTokenResult
if ($accessTokenResult -eq $null)
{
    Invoke-OAuth
}
elseif ($refreshForce -or (([datetime]::Parse("1/1/1970")).AddSeconds([int]$accessTokenResult.expires_on).ToLocalTime() -lt ([datetime]::Now).AddMinutes(5)))
{
    if ($isRefresh)
    {
        Invoke-Refresh
    }
    else
    {
        Invoke-OAuth
    }
}
else
{
    Write-Output (ConvertTo-Json $Global:accessTokenResult)
}