Category Archives: Identity

Guest User Last Sign-in date time in Azure Active Directory and automatic cleanup

Azure AD’s sign in logs also only go back 30 days, which makes it highly recommended to stream Azure AD’s sign in logs to a Log Analytics workspace (Azure Monitor). You just need one single P1 license in your tenant to be able to enable this.

However, even if you don’t stream your sign in logs, Microsoft does keep track of when an account last signed in.

My script gets the last sign in data of all guest accounts in your tenant, without any dependencies other than the Az PS module.

If a guest user has never signed in, the creationDate is used to determine inactivity. Otherwise either the last interactive or last non interactive sign in is used (whichever is most recent).

Additionally, the script can also be configured to automatically clean up any guest accounts that have been inactive for a given number of days by using the -removeInactiveGuests switch.

Even in large environments, processing only takes a few minutes at most.

Download

Download the script from my Gitlab here:

https://gitlab.com/Lieben/assortedFunctions/-/blob/master/get-AzureAdInactiveGuestUsers.ps1

Limitations

Microsoft started using these properties in april 2020, so accounts active before that will seem like they have never been active.

Scheduling

This script supports running non-interactive as a runbook in Azure Automation if you supply the -nonInteractive switch. Before this will work, you’ll have to enable Managed Identity on your automation account and run a small script to assign graph permissions to the Managed Identity: AuditLog.Read.All and Organization.Read.All

Reports

If you wish, you can also let the script mail you a report in CSV format. Add the Mail.Send graph permissions like you did with device permissions and give the MailFrom and MailTo parameters a value.

Disclaimer

As always this script is provided as-is and should be reviewed and then used at your own risk.

Inviting an external user to a PowerApp programmatically

Another week, another use case for Managed Identities in Automation Accounts!

The scenario today concerns a PowerApp and connected resources that should be shared with external identities, automatically of course. For each user this requires a guest account in the host / resource tenant, and a license. The license can be applied in the home tenant of the guest, or in your tenant.

Key points:

  1. Runbook that invites a user and adds the resulting guest account to a security group
  2. Security group gives access to the PowerApp and underlying (SpO) resources, and uses Group Based Licensing to license the guest for PowerApps and Sharepoint Online
  3. Logic App that is triggered by the PowerApp (trigger on create item in a sharepoint list), and starts the runbook
  4. When the invited user (guest) redeems the invitation, they are directed to a Sharepoint page first so Sharepoint syncs their profile. Otherwise, the PowerApp will not have access to any lists in Sharepoint Online as Guests are not synced to SpO until they access SpO directly.

I may demo the PowerApp, Logic App and Sharepoint lists at some point, but the main thing I wanted to share today is the Azure Runbook that creates the Guest invitation and adds the Guest to a security group using the Managed Identity of the Automation account, instead of service accounts or other pre-2021 solutions:

https://gitlab.com/Lieben/assortedFunctions/-/blob/master/invite-guestUser.ps1

License reports by a Managed Identity

Capitalizing on the huge advantages that managed identities in Azure offer, here’s another use case similar to the scheduled migration script that also uses it’s managed identity (and graph permissions) to autonomously run as an Azure Runbook without any credentials stored.

https://gitlab.com/Lieben/assortedFunctions/-/blob/master/get-licenseReportByDomain.ps1

The script will log in to Graph, retrieve all unique license types and how they are assigned to users, and will then email an HTML report (table) the the specified recipient, order by email domain.

Sensitive group protection

It is best practise in IT to secure access to resources with Groups.

Membership of a security group means access to whatever resources are secured by that group. Sometimes these groups are self-managed by an owner, sometimes centrally.

In all cases, fairly low privileged users, that are not global admins, can add users to these groups including themselves. Imagine that you have a group called ‘Global Admins’, and your helpdesk user assigns himself to that group. You’d like to know right?

With Privileged Access Groups in Azure AD (Preview) you can protect groups like these actively, but, this requires a P2 license and still lacks some customization features.

An alternative method is to use a simple alerting rule in MCAS (Microsoft Cloud App Security), where you set an alert when ‘someone’ joins a specific group, or if you want to do more than alerting you could also run an automation playbook.

Here’s how to protect a specific Azure AD or Office 365 group with MCAS:

  1. look up its GUID in AzureAD
  2. Create an Activity Policy in the MCAS console
  3. Specify the group GUID as ‘Activity object ID’ in the policy and the correct action type:

Calling Graph and other API’s silently for an MFA enabled account

The Graph and other Microsoft API’s should be called using a Service Principal whenever possible. But some endpoints (such as the ‘hidden’ azure api) don’t support service principals and require an actual user to call it.

Of course, users that have privileges in your organisation are protected with MFA / conditional access or you wouldn’t be reading my blog 🙂

Below script circumvents MFA by hijacking a refresh token which normally isn’t returned/exposed to the user. It then encrypts and caches it locally and refreshes and reuses it the next time it is called. As refresh tokens expire after 90 days of inactivity by default, you won’t see an MFA prompt again as long as the script runs at least once every 90 days.

Requirements:

  1. Az.Accounts module
  2. User account
  3. Onetime MFA prompt completion

Method 1: Code at Gitlab (requires Az.Accounts module)

Method 2: Code at GitLab (does not require any modules)

Method 1 example:

<#
    .SYNOPSIS
    Retrieve graph or other azure tokens as desired (e.g. for https://main.iam.ad.ext.azure.com) and bypass MFA by repeatedly recaching the RefreshToken stolen from the TokenCache of the Az module.
    Only the first login will require an interactive login, subsequent logins will not require interactivity and will bypass MFA.

    This script is without warranty and not for commercial use without prior consent from the author. It is meant for scenario's where you need an Azure token to automate something that cannot yet be done with service principals.
    If your refresh token expires (default 90 days of inactivity) you'll have to rerun the script interactively.

    .EXAMPLE
    $graphToken = get-azResourceTokenSilently -userUPN nobody@lieben.nu
    .PARAMETER userUPN
    the UPN of the user you need a token for (that is MFA enabled or protected by a CA policy)
    .PARAMETER refreshTokenCachePath
    Path to encrypted token cache if you don't want to use the default
    .PARAMETER tenantId
    If supplied, logs in to specified tenant, optional and only required if you're using Azure B2B
    .PARAMETER resource
    Resource your token is for, e.g. "https://graph.microsoft.com" would give a token for the Graph API
    .PARAMETER refreshToken
    If supplied, this is used to update the token cache and interactive login will not be required. This parameter is meant as an alternative to that initial first time interactive login
    
    .NOTES
    filename: get-azResourceTokenSilently.ps1
    author: Jos Lieben
    blog: www.lieben.nu
    created: 09/04/2020
#>
Param(
    $refreshTokenCachePath=(Join-Path $env:APPDATA -ChildPath "azRfTknCache.cf"),
    $refreshToken,
    $tenantId,
    [Parameter(Mandatory=$true)]$userUPN,
    $resource="https://graph.microsoft.com"
)

$strCurrentTimeZone = (Get-WmiObject win32_timezone).StandardName
$TZ = [System.TimeZoneInfo]::FindSystemTimeZoneById($strCurrentTimeZone)
[datetime]$origin = '1970-01-01 00:00:00'

if(!$tenantId){
    $tenantId = (Invoke-RestMethod "https://login.windows.net/$($userUPN.Split("@")[1])/.well-known/openid-configuration" -Method GET).userinfo_endpoint.Split("/")[3]
}

if($refreshToken){
    try{
        write-verbose "checking provided refresh token and updating it"
        $response = (Invoke-RestMethod "https://login.windows.net/$tenantId/oauth2/token" -Method POST -Body "grant_type=refresh_token&refresh_token=$refreshToken" -ErrorAction Stop)
        $refreshToken = $response.refresh_token
        $AccessToken = $response.access_token
        write-verbose "refresh and access token updated"
    }catch{
        Write-Output "Failed to use cached refresh token, need interactive login or token from cache"   
        $refreshToken = $False 
    }
}

if([System.IO.File]::Exists($refreshTokenCachePath) -and !$refreshToken){
    try{
        write-verbose "getting refresh token from cache"
        $refreshToken = Get-Content $refreshTokenCachePath -ErrorAction Stop | ConvertTo-SecureString -ErrorAction Stop
        $refreshToken = [System.Runtime.InteropServices.Marshal]::SecureStringToBSTR($refreshToken)
        $refreshToken = [System.Runtime.InteropServices.Marshal]::PtrToStringAuto($refreshToken)
        $response = (Invoke-RestMethod "https://login.windows.net/$tenantId/oauth2/token" -Method POST -Body "grant_type=refresh_token&refresh_token=$refreshToken" -ErrorAction Stop)
        $refreshToken = $response.refresh_token
        $AccessToken = $response.access_token
        write-verbose "tokens updated using cached token"
    }catch{
        Write-Output "Failed to use cached refresh token, need interactive login"
        $refreshToken = $False
    }
}

#full login required
if(!$refreshToken){
    Write-Verbose "No cache file exists and no refresh token supplied, perform interactive logon"
    if ([Environment]::UserInteractive) {
        foreach ($arg in [Environment]::GetCommandLineArgs()) {
            if ($arg -like '-NonI*') {
                Throw "Interactive login required, but script is not running interactively. Run once interactively or supply a refresh token with -refreshToken"
            }
        }
    }

    Import-Module az.accounts -erroraction silentlycontinue | out-null

    if(!(Get-Module -Name "Az.Accounts")){
        Throw "Az.Accounts module not installed!"
    }
    Write-Verbose "Calling Login-AzAccount"
    if($tenantId){
        $Null = Login-AzAccount -Tenant $tenantId -ErrorAction Stop
    }else{
        $Null = Login-AzAccount -ErrorAction Stop
    }

    #if login worked, we should have a Context
    $context = [Microsoft.Azure.Commands.Common.Authentication.Abstractions.AzureRmProfileProvider]::Instance.Profile.DefaultContext
    if($context){
        Write-verbose "logged in, checking local refresh tokens..."
        $string = [System.Text.Encoding]::Default.GetString($context.TokenCache.CacheData)
        $marker = 0
        $tokens = @()
        while($true){
            $marker = $string.IndexOf("https://",$marker)
            if($marker -eq -1){break}
            $uri = $string.SubString($marker,$string.IndexOf("RefreshToken",$marker)-4-$marker)
            $marker = $string.IndexOf("RefreshToken",$marker)+15
            if($string.Substring($marker+2,4) -ne "null"){
                $refreshtoken = $string.SubString($marker,$string.IndexOf("ResourceInResponse",$marker)-3-$marker)
                $marker = $string.IndexOf("ExpiresOn",$marker)+31
                $expirydate = $string.SubString($marker,$string.IndexOf("OffsetMinutes",$marker)-6-$marker)
                $tokens += [PSCustomObject]@{"expiresOn"=[System.TimeZoneInfo]::ConvertTimeFromUtc($origin.AddMilliseconds($expirydate), $TZ);"refreshToken"=$refreshToken;"target"=$uri}
            }
        }       
        $refreshToken = @($tokens | Where-Object {$_.expiresOn -gt (get-Date)} | Sort-Object -Descending -Property expiresOn)[0].refreshToken
        write-verbose "updating stolen refresh token"
        $response = (Invoke-RestMethod "https://login.windows.net/$tenantId/oauth2/token" -Method POST -Body "grant_type=refresh_token&refresh_token=$refreshToken" -ErrorAction Stop)
        $refreshToken = $response.refresh_token
        $AccessToken = $response.access_token
        write-verbose "tokens updated"

    }else{
        Throw "Login-AzAccount failed, cannot continue"
    }
}

if($refreshToken){
    write-verbose "caching refresh token"
    Set-Content -Path $refreshTokenCachePath -Value ($refreshToken | ConvertTo-SecureString -AsPlainText -Force -ErrorAction Stop | ConvertFrom-SecureString -ErrorAction Stop) -Force -ErrorAction Continue | Out-Null
    write-verbose "refresh token cached"
}else{
    Throw "No refresh token found in cache and no valid refresh token passed or received after login, cannot continue"
}

if($AccessToken){
    write-verbose "update token for supplied resource"
    $null = Login-AzAccount -AccountId $userUPN -AccessToken $AccessToken
    $context = [Microsoft.Azure.Commands.Common.Authentication.Abstractions.AzureRmProfileProvider]::Instance.Profile.DefaultContext
    $resourceToken = [Microsoft.Azure.Commands.Common.Authentication.AzureSession]::Instance.AuthenticationFactory.Authenticate($context.Account, $context.Environment, $context.Tenant.Id.ToString(), $null, [Microsoft.Azure.Commands.Common.Authentication.ShowDialog]::Never, $null, $resource).AccessToken
}else{
    Throw "Failed to translate access token to $resource , cannot continue"
}

return $resourceToken

This post was inspired by a use case Mark had for Senserva.com