Login: forward operator-chosen resource through Az PS + az cli

The three login scripts were minting only management.azure.com tokens
regardless of the resource the operator picked in the UI dropdown -
because none of the pieces in the chain honoured it. az cli was
similarly minting only its default ARM token. Now the operator's
choice reaches both toolchains end-to-end.

- server/scripts/login_azure.ps1
- server/scripts/login_azure_devicecode.ps1
- server/scripts/login_spn_azure.ps1
    Add `[string]$Resource='https://management.azure.com'` param.
    Get-AzAccessToken now runs with -ResourceUrl $Resource (previously
    hardcoded to https://management.azure.com or omitted, defaulting
    to ARM). After `az login` succeeds, also fetch a resource-specific
    token via `az account get-access-token --resource $Resource -o tsv`
    so both Az PS and az cli have a warm token cache for the chosen
    audience. Device-code hint now includes the exact
    `az account get-access-token --resource ...` line.

- server/Server.cpp
    handleNewSession reads req["resource"] (default ARM) into a local
    and forwards it as `-Resource '<escaped>'` on all three script
    invocations (credential/ROPC, device-code, SPN). Uses the existing
    escapePsString helper. Also stops the SPN meta-write from
    hardcoding management.azure.com - it now records the actual
    resource. The old ternary that always returned ARM is replaced
    with a plain default.

- client/CredentialLoginWindow.{h,cpp}
    Dropdown expanded from 2 to 6 entries (Management, Graph, Key
    Vault, Storage, SQL, Other...). currentData() carries the resource
    URL - same pattern SPNLoginWindow already uses. "Other..." reveals
    a free-text QLineEdit; a trailing "/.default" scope suffix is
    stripped so -ResourceUrl gets a v1 audience URL.

- client/DeviceCodeLoginWindow.cpp
    startFullServerSession() no longer hardcodes ARM - reads
    resourceInput->text() (the free-text field that was already in the
    UI but silently ignored). Empty falls back to ARM; a trailing
    /.default is stripped for the same reason.

- client/SPNLoginWindow.cpp
    authenticateViaPowerShell() no longer hardcodes ARM in req -
    forwards pendingResource (already populated from the dropdown at
    handler entry). Empty falls back to ARM defensively.
This commit is contained in:
David Garcia
2026-07-29 06:50:37 -06:00
parent 18746cae06
commit 2e774b2b6d
8 changed files with 86 additions and 21 deletions
+33 -6
View File
@@ -31,10 +31,30 @@ void CredentialLoginWindow::setupUi() {
layout->addWidget(new QLabel("Select resource and enter credentials"));
resourceDropdown = new QComboBox(this);
resourceDropdown->addItem("Azure Management (management.azure.com)");
resourceDropdown->addItem("Microsoft Graph (graph.microsoft.com)");
resourceDropdown->addItem("Azure Management (management.azure.com)",
QStringLiteral("https://management.azure.com"));
resourceDropdown->addItem("Microsoft Graph (graph.microsoft.com)",
QStringLiteral("https://graph.microsoft.com"));
resourceDropdown->addItem("Key Vault (vault.azure.net)",
QStringLiteral("https://vault.azure.net"));
resourceDropdown->addItem("Azure Storage (storage.azure.com)",
QStringLiteral("https://storage.azure.com"));
resourceDropdown->addItem("SQL Database (database.windows.net)",
QStringLiteral("https://database.windows.net"));
resourceDropdown->addItem("Other...", QStringLiteral(""));
layout->addWidget(resourceDropdown);
// Free-text field shown only when the operator picks "Other..."
customResource = new QLineEdit(this);
customResource->setPlaceholderText("Custom resource URL, e.g. https://vault.azure.net");
customResource->setVisible(false);
layout->addWidget(customResource);
connect(resourceDropdown, QOverload<int>::of(&QComboBox::currentIndexChanged),
this, [this](int) {
const bool isOther = resourceDropdown->currentData().toString().isEmpty();
customResource->setVisible(isOther);
});
username = new QLineEdit(this);
username->setPlaceholderText("e.g. alice@contoso.com");
layout->addWidget(username);
@@ -97,10 +117,17 @@ void CredentialLoginWindow::handleLogin() {
sessionHandled = false;
if (resourceDropdown->currentIndex() == 1)
pendingResource = QStringLiteral("https://graph.microsoft.com");
else
pendingResource = QStringLiteral("https://management.azure.com");
pendingResource = resourceDropdown->currentData().toString();
if (pendingResource.isEmpty()) {
// "Other..." selected - use the free-text field. Empty falls back to ARM.
pendingResource = customResource->text().trimmed();
if (pendingResource.isEmpty())
pendingResource = QStringLiteral("https://management.azure.com");
// Strip a trailing "/.default" scope suffix so -ResourceUrl gets a
// v1 audience URL.
if (pendingResource.endsWith(QStringLiteral("/.default")))
pendingResource.chop(QStringLiteral("/.default").size());
}
pendingUsername = user;
pendingRid = QUuid::createUuid().toString(QUuid::WithoutBraces);
+1
View File
@@ -49,6 +49,7 @@ private:
private:
DashboardWindow *parentDashboard = nullptr;
QComboBox *resourceDropdown = nullptr;
QLineEdit *customResource = nullptr; // Shown only when "Other..." is picked
QLineEdit *username = nullptr;
QLineEdit *password = nullptr;
+10 -1
View File
@@ -197,7 +197,16 @@ void DeviceCodeLoginWindow::startFullServerSession() {
fullSessionMode = true;
fullSessionSid = QUuid::createUuid().toString(QUuid::WithoutBraces);
pendingRid = QUuid::createUuid().toString(QUuid::WithoutBraces);
pendingResource = QStringLiteral("https://management.azure.com");
// Honour the operator-typed resource. The field default is the v2 SCOPE form
// (".../.default"); Az PS's -ResourceUrl wants the v1 audience without the
// suffix, so strip it. Empty -> ARM default.
pendingResource = resourceInput->text().trimmed();
if (pendingResource.isEmpty()) {
pendingResource = QStringLiteral("https://management.azure.com");
} else if (pendingResource.endsWith(QStringLiteral("/.default"))) {
pendingResource.chop(QStringLiteral("/.default").size());
}
createStatusWidget(fullSessionSid);
if (statusWidgets.contains(fullSessionSid))
+8 -1
View File
@@ -391,10 +391,17 @@ void SPNLoginWindow::authenticateViaPowerShell(const QString &appId,
pendingRid = QUuid::createUuid().toString(QUuid::WithoutBraces);
// Use whatever resource the operator picked in the dropdown - not a
// hardcoded ARM. pendingResource is populated when the user changes the
// dropdown; if it's empty (window opened but never touched), default to
// ARM.
QString resource = pendingResource;
if (resource.isEmpty()) resource = QStringLiteral("https://management.azure.com");
QJsonObject req;
req.insert(Protocol::F_ACTION, Protocol::ACTION_NEW_SESSION);
req.insert("mode", QStringLiteral("spn"));
req.insert("resource", QStringLiteral("https://management.azure.com"));
req.insert("resource", resource);
req.insert("appId", appId);
req.insert("clientSecret", secret);
req.insert("tenantId", tenantId);
+9 -8
View File
@@ -862,8 +862,8 @@ bool Server::handleLine(QTcpSocket *sock, const QByteArray &line) {
// ── Create session (credentials or raw) ────────────────────────────────────
if (action == Protocol::ACTION_NEW_SESSION) {
const QString mode = obj.value("mode").toString();
const QString resource = obj.value("resource").toString(APP_CONFIG.defaultClientId().isEmpty()
? QStringLiteral("https://management.azure.com") : QStringLiteral("https://management.azure.com"));
QString resource = obj.value("resource").toString().trimmed();
if (resource.isEmpty()) resource = QStringLiteral("https://management.azure.com");
QString sid = obj.value("sessionId").toString().trimmed();
if (sid.isEmpty()) sid = QUuid::createUuid().toString(QUuid::WithoutBraces);
const QString rid = obj.value("rid").toString();
@@ -1000,8 +1000,8 @@ bool Server::handleLine(QTcpSocket *sock, const QByteArray &line) {
}
// Use single quotes with proper escaping to prevent command injection
QString execCmd = QString(". \"%1\" -Username '%2' -Password '%3'\n")
.arg(ps1Path, escapePsString(user), escapePsString(pass));
QString execCmd = QString(". \"%1\" -Username '%2' -Password '%3' -Resource '%4'\n")
.arg(ps1Path, escapePsString(user), escapePsString(pass), escapePsString(resource));
proc->write(execCmd.toUtf8());
QJsonObject ack = Protocol::ok("new_session ok");
@@ -1039,7 +1039,8 @@ bool Server::handleLine(QTcpSocket *sock, const QByteArray &line) {
}
// No credentials to pass - the device-code prompt streams to the Session Tab.
QString execCmd = QString(". \"%1\"\n").arg(ps1Path);
QString execCmd = QString(". \"%1\" -Resource '%2'\n")
.arg(ps1Path, escapePsString(resource));
proc->write(execCmd.toUtf8());
QJsonObject ack = Protocol::ok("new_session ok");
@@ -1107,15 +1108,15 @@ bool Server::handleLine(QTcpSocket *sock, const QByteArray &line) {
spnMeta.insert("user", appId);
spnMeta.insert("tenantId", tenantId);
spnMeta.insert("domain", QStringLiteral("ServicePrincipal"));
spnMeta.insert("resource", QStringLiteral("https://management.azure.com"));
spnMeta.insert("resource", resource);
g_sessionInfo.insert(sid, spnMeta);
}
SessionDBManager::instance().updateSessionUser(sid, appId);
SessionDBManager::instance().updateSessionTenant(sid, tenantId, QStringLiteral("ServicePrincipal"));
// Pass only the credential file path, not the secret itself
QString execCmd = QString(". \"%1\" -CredentialFile \"%2\"\n")
.arg(ps1Path, credPath);
QString execCmd = QString(". \"%1\" -CredentialFile \"%2\" -Resource '%3'\n")
.arg(ps1Path, credPath, escapePsString(resource));
proc->write(execCmd.toUtf8());
// The script deletes this after reading, but wipe it here too in case
+11 -2
View File
@@ -1,4 +1,4 @@
param([string]$Username,[string]$Password)
param([string]$Username,[string]$Password,[string]$Resource='https://management.azure.com')
$ErrorActionPreference='Stop'
$mfa='AADSTS50076|AADSTS50079|AADSTS50158|AADSTS53003|AADSTS50074|AADSTS500121'
try {
@@ -11,7 +11,7 @@ try {
Connect-AzAccount -Credential $c -EA Stop -WA Ignore|Out-Null
$ctx=Get-AzContext -EA SilentlyContinue
if($ctx-and$ctx.Account){
try{$t=(Get-AzAccessToken -ResourceUrl 'https://management.azure.com' -EA Stop).Token;Write-Output "__ANIMO_TOKEN__:$t"}catch{}
try{$t=(Get-AzAccessToken -ResourceUrl $Resource -EA Stop).Token;Write-Output "__ANIMO_TOKEN__:$t"}catch{}
# Also log az cli in with the same ROPC creds so operators can use az one-liners
# in the same terminal (az cli keeps a separate token cache from Az PS).
try {
@@ -22,6 +22,15 @@ try {
$azOut = (& az @azArgs) 2>&1
if ($LASTEXITCODE -eq 0) {
Write-Output "[Animo] az cli logged in (both Az PS and az cli contexts active)"
# Also mint a resource-specific token via az cli so both toolchains
# have a matching token for the chosen resource. Silent on failure -
# the Az PS __ANIMO_TOKEN__ above is what the server captures.
try {
$azTok = (& az account get-access-token --resource $Resource -o tsv --query accessToken 2>$null)
if ($LASTEXITCODE -eq 0 -and $azTok) {
Write-Output "[Animo] az cli minted token for $Resource"
}
} catch {}
} else {
Write-Output ("[Animo] az cli login failed (exit {0}): {1}" -f $LASTEXITCODE, ($azOut | Out-String).Trim())
}
+3 -1
View File
@@ -1,3 +1,4 @@
param([string]$Resource='https://management.azure.com')
$ErrorActionPreference='Stop'
# Full interactive login: device code runs INSIDE Az, so the context keeps a real
# token cache + refresh token. Unlike -AccessToken, this lets Get-AzAccessToken mint
@@ -12,7 +13,7 @@ try {
ForEach-Object { Write-Output $_ }
$ctx=Get-AzContext -EA SilentlyContinue
if($ctx-and$ctx.Account){
try{$t=(Get-AzAccessToken -ResourceUrl 'https://management.azure.com' -EA Stop).Token;Write-Output "__ANIMO_TOKEN__:$t"}catch{}
try{$t=(Get-AzAccessToken -ResourceUrl $Resource -EA Stop).Token;Write-Output "__ANIMO_TOKEN__:$t"}catch{}
# az cli is a separate binary with its own token cache. For device-code
# login we deliberately don't call `az login` here - it would prompt the
# operator for a SECOND device code, which is confusing. If they need az
@@ -20,6 +21,7 @@ try {
try {
if (Get-Command az -EA SilentlyContinue) {
Write-Output "[Animo] To use az cli in this session run: az login --use-device-code --tenant $($ctx.Tenant.Id)"
Write-Output "[Animo] Then: az account get-access-token --resource $Resource"
}
} catch {}
Write-Output "__ANIMO_LOGIN_OK__:$($ctx.Account)"
+11 -2
View File
@@ -1,4 +1,4 @@
param([string]$CredentialFile)
param([string]$CredentialFile,[string]$Resource='https://management.azure.com')
$ErrorActionPreference='Stop'
try {
if(-not(Test-Path $CredentialFile)){throw "Credential file not found"}
@@ -37,6 +37,15 @@ try {
$azOut = (& az login --service-principal -u $AppId -p $ClientSecret --tenant $TenantId --allow-no-subscriptions --output none) 2>&1
if ($LASTEXITCODE -eq 0) {
Write-Output "[Animo] az cli logged in as SPN (both Az PS and az cli contexts active)"
# Warm the az cli token cache for the chosen resource so both
# toolchains have parity. Silent on failure - Az PS still emitted
# __ANIMO_TOKEN__ below.
try {
$azTok = (& az account get-access-token --resource $Resource -o tsv --query accessToken 2>$null)
if ($LASTEXITCODE -eq 0 -and $azTok) {
Write-Output "[Animo] az cli minted token for $Resource"
}
} catch {}
} else {
Write-Output ("[Animo] az cli login failed (exit {0}): {1}" -f $LASTEXITCODE, ($azOut | Out-String).Trim())
}
@@ -50,7 +59,7 @@ try {
$ctx=Get-AzContext -EA SilentlyContinue
if($ctx-and$ctx.Account){
try{
$tok=Get-AzAccessToken -EA SilentlyContinue
$tok=Get-AzAccessToken -ResourceUrl $Resource -EA SilentlyContinue
if($tok-and$tok.Token){Write-Output "__ANIMO_TOKEN__:$($tok.Token)"}
}catch{}
Write-Output "__ANIMO_LOGIN_OK__:$($ctx.Account)"