ldo works as you: it signs in with your own account and can read only what you can.
auth |
When | Needs |
|---|---|---|
azure-cli (the default) |
almost always: it reuses the sign-in you already have | az login |
interactive |
commands whose scopes the Azure CLI's token never carries: PIM on Entra roles and groups, incidents, Graph hunting | client_id of your own app; signs in in a browser |
device-code |
the same, over SSH, in WSL, in a container, anywhere without a browser | the same client_id; you enter a code at the device login page |
ldo az use prod-tenant # switch the Azure CLI to a profile, signing in when needed
ldo az use prod-tenant --device-code
ldo az whoami # the Azure CLI's active account, and the profile it matches
ldo entra sign-out -p pim # forget an interactive or device-code profile's kept sign-inTokens are requested per tenant, so az use is optional: a profile for another tenant works
without switching. An interactive profile falls back to a device code when no browser can
be opened.
Unattended jobs can run the same commands with an identity of their own:
auth |
For | Needs |
|---|---|---|
client-secret |
a job with an app registration and a secret | client_id, and the secret in AZURE_CLIENT_SECRET |
workload-identity |
CI and Kubernetes, with no secret at all | client_id, and a federated token from AZURE_FEDERATED_TOKEN_FILE or GitHub Actions OIDC |
managed-identity |
code on an Azure host | nothing, or client_id for a user-assigned identity |
These never lapse: every token is a fresh exchange. In GitHub Actions, give the job
id-token: write and the app registration a federated credential for the workflow:
permissions:
id-token: write
contents: read
steps:
- run: uv tool install libre-devops-helpers==0.8.1
- run: ldo --config .github/ldo.toml entra app-credentials -p ci --expiring 30dAccess tokens last about an hour and are renewed for you, five minutes before they expire,
so a two-hour devices watch carries on by itself. A 401 drops the cached token and retries
once. Your sign-in behind them lasts days or weeks, but Entra ID ends it in cases no code can
avoid:
| Cause | Entra ID code | Again? |
|---|---|---|
| a sign-in frequency policy (every 8 hours, say) | AADSTS70043, AADSTS70044 | yes, on that schedule |
| unused for too long (90 days) | AADSTS700082 | only after a long gap |
| a password change or expiry | AADSTS50132, AADSTS50133, AADSTS50055 | no |
| revoked by an administrator, or "sign out everywhere" | AADSTS50173 | no |
| MFA newly required, expired, or not set up | AADSTS50076, AADSTS50078, AADSTS50079 | depends on the policy |
The error names the cause. On a terminal, ldo offers to sign the Azure CLI back in to that
tenant and carries on, then restores whichever account was active. devices check and
watch ask between passes and repeat the pass a lapse interrupted. Scripts and CI never see
the question. LDO_REAUTH=device-code uses a device code; LDO_REAUTH=off never asks.
interactive and device-code profiles sign in again by themselves, and say why.
After activating a role in PIM, the Azure CLI may hand out its old token (and old
permissions) for up to an hour; an interactive or device-code profile starts each command
with a new one.
An interactive or device-code profile keeps its refresh token, so the next command signs
in without asking. Access tokens are never kept. token_cache on the profile says where:
token_cache |
Kept in | Use it on |
|---|---|---|
file (the default) |
~/.local/state/ldo/refresh-tokens.json (%LOCALAPPDATA%\ldo on Windows), readable only by you |
your own machines, headless or not |
keychain |
macOS Keychain, the Secret Service on Linux, or a DPAPI-encrypted file on Windows | a desktop where you want it locked away |
memory |
nowhere: gone when the command ends | shared machines and jump hosts |
A refresh token is as good as your sign-in to that app until it expires or is revoked. The
file is the trade the Azure CLI makes on Linux: fine on a machine that is yours. A cache file
other accounts can read is refused, as ssh refuses a readable key. Commands running at once,
in two terminals, say, take turns to change the file (a lock file sits beside it), so
neither loses the other's sign-in. keychain on macOS and
Linux needs the keychain extra:
uv tool install "libre-devops-helpers[keychain]"The Azure CLI's token carries the scopes Microsoft chose, and the PIM, incident and hunting scopes are not among them. Register a public client app once per tenant, grant it the delegated read scopes, and have an administrator consent:
app=$(az ad app create --display-name "ldo (delegated sign-in)" \
--public-client-redirect-uris http://localhost --is-fallback-public-client true \
--query appId -o tsv)
graph=00000003-0000-0000-c000-000000000000 # Microsoft Graph
arm=797f4846-ba00-4fd7-ba43-dac1f8f63013 # Azure Service Management
for scope in Directory.Read.All RoleEligibilitySchedule.Read.Directory \
RoleAssignmentSchedule.ReadWrite.Directory RoleManagementPolicy.Read.Directory \
PrivilegedEligibilitySchedule.Read.AzureADGroup PrivilegedAssignmentSchedule.ReadWrite.AzureADGroup \
RoleManagementPolicy.Read.AzureADGroup SecurityIncident.Read.All ThreatHunting.Read.All \
ServiceMessage.Read.All Tasks.ReadWrite; do
id=$(az ad sp show --id $graph --query "oauth2PermissionScopes[?value=='$scope'].id" -o tsv)
az ad app permission add --id "$app" --api $graph --api-permissions "$id=Scope"
done
id=$(az ad sp show --id $arm --query "oauth2PermissionScopes[?value=='user_impersonation'].id" -o tsv)
az ad app permission add --id "$app" --api $arm --api-permissions "$id=Scope"
az ad app permission admin-consent --id "$app"
echo "client_id = \"$app\""[microsoft.profiles.me]
tenant_id = "<tenant guid>"
auth = "interactive" # or "device-code"
client_id = "<the app id printed above>"Graph insists on a ReadWrite scope even to list PIM requests; ldo only ever reads.