Skip to content

Commit 94c77f7

Browse files
authored
Merge branch 'main' into renovate/actions-checkout-6.x
2 parents 7bb9e31 + d2b8e81 commit 94c77f7

21 files changed

Lines changed: 982 additions & 72 deletions
Lines changed: 60 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,60 @@
1+
# Fix: `atmos auth login` corrupts the Azure CLI cache for guest (B2B) users
2+
3+
**Date:** 2026-08-03
4+
5+
## Summary
6+
7+
After `atmos auth login` with any Azure provider, the Azure CLI cache write-back created an
8+
MSAL Account entry with `home_account_id` derived as `{oid}.{target-tenant}` and a hardcoded
9+
`account_source: "device_code"`. For guest (B2B) users the home tenant differs from the tenant
10+
being accessed, so `~/.azure/msal_token_cache.json` ended up with two Account entries for the
11+
same username, and every subsequent az command failed with
12+
`Found multiple accounts with the same username`
13+
([azure-cli#20168](https://github.com/Azure/azure-cli/issues/20168)) — including
14+
`az account get-access-token`, which the `azure/cli` provider itself shells out to. One
15+
`atmos auth login` therefore broke both az and the next atmos login for any guest user.
16+
Fixed in [#2861](https://github.com/cloudposse/atmos/pull/2861).
17+
18+
## Context
19+
20+
Reproduced end to end against a real tenant where the operator is a B2B guest
21+
(`user@home-tenant.com` signing in to a different tenant): `az login`
22+
`atmos auth login` (azure/cli provider) → az broken. Two separate write paths produced the bad
23+
entry: `UpdateAzureCLIFiles` (called from the `azure/subscription` identity's PostAuthenticate)
24+
and the device-code provider's own `updateAzureCLICache`. Both derived the MSAL
25+
`home_account_id` from the *target* tenant, while az records the *home* tenant
26+
(`{home-oid}.{home-tenant}`), producing a same-username duplicate that MSAL refuses to
27+
disambiguate.
28+
29+
Two contributing design gaps:
30+
31+
- Credentials minted **by** the Azure CLI were written **back** into the CLI's own cache —
32+
pure corruption risk with zero benefit, since az's cache is already authoritative for that
33+
session. (The write-back is load-bearing only for MSAL-based flows like device code, where
34+
it's what lets Terraform's `azurerm`/`azuread` providers authenticate via `ARM_USE_CLI`.)
35+
- The `azure/subscription` identity rebuilt `AzureCredentials` field-by-field when wrapping
36+
provider credentials, silently dropping any newly added field. This initially defeated the
37+
fix (the new `AuthMethod`/`HomeAccountID` fields never reached the cache writer) and was only
38+
caught by the end-to-end guest-tenant test.
39+
40+
## Changes
41+
42+
- `pkg/auth/types/azure_credentials.go`: `AzureCredentials` gains `AuthMethod` (which provider
43+
kind minted the credentials: `cli` / `device_code` / `oidc`, joined by `interactive` when
44+
the `azure/interactive` provider was added) and `HomeAccountID` (MSAL's
45+
`{home-oid}.{home-tenant-id}`).
46+
- `pkg/auth/cloud/azure/setup.go`: `UpdateAzureCLIFiles` skips the write-back entirely for
47+
CLI-sourced credentials; `addUserAccountAndTokens` prefers the MSAL home account ID over the
48+
`{oid}.{target-tenant}` derivation.
49+
- `pkg/auth/providers/azure/device_code.go` / `device_code_cache.go`: the device-code provider
50+
captures `AuthResult.Account.HomeAccountID` in both silent and interactive flows
51+
(`captureHomeAccountID`) and threads it through its own cache writer.
52+
- `pkg/auth/identities/azure/subscription.go`: the identity wrap is now a struct copy with
53+
explicit overrides; a reflection-based regression test fails if any future field is dropped.
54+
- Coverage: `azure/cli` Authenticate is tested end to end via a stubbed `az` on `PATH`;
55+
the device-code silent/headless paths and the identity PostAuthenticate happy path are
56+
covered with a sandboxed `HOME`.
57+
58+
## Recovery
59+
60+
A corrupted cache is repaired with `az account clear && az login --tenant <tenant-id>`.

docs/prd/azure-interactive-auth.md

Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
# Azure Interactive Browser Authentication (`azure/interactive`)
2+
3+
**Status**: Implemented
4+
**Last Updated**: 2026-08-04
5+
**Owners**: Atmos auth subsystem
6+
7+
**Upstream references** (verified via Microsoft docs):
8+
- MSAL — [Interactive and non-interactive authentication flows](https://learn.microsoft.com/en-us/entra/identity-platform/msal-authentication-flows)
9+
- MSAL Go — `public.Client.AcquireTokenInteractive`: [pkg.go.dev/github.com/AzureAD/microsoft-authentication-library-for-go/apps/public](https://pkg.go.dev/github.com/AzureAD/microsoft-authentication-library-for-go/apps/public)
10+
- Microsoft Entra — [Microsoft-managed Conditional Access policies](https://learn.microsoft.com/en-us/entra/identity/conditional-access/managed-policies) (device code flow blocking)
11+
- Azure CLI — [Sign in with Azure CLI](https://learn.microsoft.com/en-us/cli/azure/authenticate-azure-cli) (the flow this provider mirrors)
12+
13+
**Related Atmos PRDs**:
14+
- [PRD-Atmos-Auth](../../pkg/auth/docs/PRD/PRD-Atmos-Auth.md) (umbrella)
15+
- Fix doc: [Azure CLI cache corruption for guest users](../fixes/2026-08-03-azure-cli-cache-corruption-guest-users.md) (#2861 — provides the `AuthMethod`/`HomeAccountID` plumbing this feature builds on)
16+
17+
---
18+
19+
## 1. Executive Summary
20+
21+
### Problem
22+
23+
Atmos had no one-command human login for Azure that works under modern tenant policy:
24+
25+
- `azure/device-code` performs a self-contained login, but Microsoft-managed Conditional
26+
Access policies now **block the device code flow** in many tenants (error `AADSTS530035`),
27+
because device code is a phishing vector. Microsoft is rolling these managed policies out
28+
broadly, so this failure mode grows over time rather than shrinking.
29+
- `azure/cli` delegates to an existing `az login` session — it can never be one command, and
30+
it requires the Azure CLI to be installed and logged in first.
31+
- `azure/oidc` is CI-only (workload identity federation).
32+
33+
On AWS, `atmos auth login` is a single command (`aws/iam-identity-center`). Azure users had
34+
no equivalent.
35+
36+
### Solution
37+
38+
A new provider kind **`azure/interactive`** implementing MSAL interactive browser
39+
authentication — authorization code + PKCE on a localhost redirect, the exact flow
40+
`az login` uses (and MSAL's own name for it: `AcquireTokenInteractive`). Conditional Access
41+
allows it because the browser session carries full CA context (MFA, device state, sign-in
42+
risk).
43+
44+
`atmos auth login` opens the browser, the user signs in, and atmos acquires Management,
45+
Graph, and Key Vault tokens, persists them to the realm-scoped MSAL cache (refresh tokens
46+
make repeat logins silent), and writes the Azure CLI-compatible cache files — so Terraform's
47+
`azurerm`/`azuread` providers authenticate via `ARM_USE_CLI`, and the `az` CLI itself works
48+
without ever running `az login`.
49+
50+
## 2. Design
51+
52+
### Provider kind
53+
54+
`azure/interactive` — named for the mechanism (MSAL "interactive" flow), consistent with the
55+
repo convention that kinds name auth mechanisms, not UX (`aws/iam-identity-center`,
56+
`gcp/workload-identity-federation`). The spec shape is identical to `azure/device-code`:
57+
`tenant_id` (required), `subscription_id`, `location`, `client_id` (defaults to the Azure
58+
CLI public client `04b07795-8ddb-461a-bbee-02f9e1bf7b46`, which pre-authorizes localhost
59+
redirects), `cloud_environment` (public | usgovernment | china).
60+
61+
### Implementation shape
62+
63+
`interactiveProvider` embeds `deviceCodeProvider` and reuses its MSAL client construction,
64+
silent token acquisition, Graph/Key Vault token fan-out, and Azure CLI cache write-back.
65+
Only the acquisition step differs (`AcquireTokenInteractive` vs. the device code flow). The
66+
shared machinery is parameterized by auth method: credentials persist
67+
`auth_method: interactive`, and the MSAL cache `account_source` mirrors az's own labels
68+
(`authorization_code` for the browser flow, `device_code` otherwise).
69+
70+
Authentication order:
71+
1. Silent acquisition from the persisted MSAL cache (refresh tokens survive restarts — no
72+
browser on repeat logins within the refresh window).
73+
2. Interactive browser flow, guarded by a TTY check (headless environments get an error
74+
directing CI/CD to `azure/oidc` and browser-less human sessions to `azure/device-code`
75+
where tenant policy allows it).
76+
77+
Guest (B2B) users are handled correctly: the MSAL `AuthResult` supplies the real
78+
`{home-oid}.{home-tenant}` home account ID, which flows into both cache writers (see the
79+
related fix doc).
80+
81+
### Testability
82+
83+
The MSAL interactive acquisition requires a live identity provider and a browser, so the
84+
provider exposes two injection seams (`acquireInteractive`, `checkInteractive`) per the
85+
repo's dependency-injection convention. Tests cover the full success path, acquisition
86+
failure, and headless refusal against a sandboxed `HOME`.
87+
88+
## 3. Non-goals
89+
90+
- Replacing `azure/device-code` (still valid where a browser cannot run and the tenant
91+
allows the flow) or `azure/cli` (still valid to piggyback on an existing az session).
92+
- Embedded/webview sign-in, brokered auth (WAM), or Entra device registration.
93+
- Tenants requiring a custom app registration: supported via `spec.client_id`, but
94+
provisioning that registration is out of scope.
95+
96+
## 4. Acceptance
97+
98+
- `atmos auth login` with an `azure/interactive` provider opens the default browser,
99+
completes SSO (including MFA under Conditional Access), and mints ARM/Graph/Key Vault
100+
tokens — verified end to end in a tenant where the device code flow is blocked by a
101+
Microsoft-managed policy and the operator is a B2B guest.
102+
- Repeat logins are silent via the MSAL refresh token.
103+
- After login, `az account show` works without `az login`, and the az MSAL cache contains a
104+
single, correctly-keyed Account entry.
105+
- Headless environments fail fast with guidance toward `azure/oidc` (CI/CD) or
106+
`azure/device-code` (browser-less human sessions).
107+
108+
### Verification (manual, 2026-08-04)
109+
110+
Executed end to end in a real Entra tenant where the device code flow is blocked by a
111+
Microsoft-managed Conditional Access policy (`AADSTS530035`), with an operator who is a
112+
guest (B2B) user in that tenant. Starting from a fully clean state
113+
(`az account clear` plus removal of `~/.azure/atmos`, `~/.azure/msal_token_cache.json`, and
114+
`~/.azure/azureProfile.json`):
115+
116+
1. `atmos auth login` — opened the default browser, SSO completed, tokens minted (~1.5h
117+
expiry). One command, no device code, no `az login`.
118+
2. `atmos auth login` again — succeeded **silently** (same token expiry, no browser),
119+
confirming refresh-token persistence in the realm-scoped MSAL cache.
120+
3. `atmos auth whoami` — reported provider, identity, subscription principal, tenant, and
121+
expiry.
122+
4. `az account show` — worked without ever running `az login`, confirming the drop-in
123+
write-back.
124+
5. Cache forensics: exactly one MSAL Account entry, `account_source: authorization_code`
125+
(matching az's own label for this flow), with a home account ID whose tenant differs
126+
from the target tenant (the guest case that previously produced the duplicate-account
127+
corruption); persisted credentials carry `auth_method: interactive` and the home account
128+
ID.
129+
130+
Known cosmetic limitation: the azureProfile written by the write-back records the
131+
subscription ID as the subscription's display name (atmos does not query ARM for the
132+
display name during login), so `az account show` shows the ID in the `name` field until the
133+
user runs `az login` themselves. Functionality is unaffected.

pkg/auth/cloud/azure/setup.go

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -277,6 +277,7 @@ func updateMSALCacheFromCreds(home string, azureCreds *types.AzureCredentials, u
277277
KeyVaultExpiration: azureCreds.KeyVaultExpiration,
278278
UserOID: userOID,
279279
TenantID: tenantID,
280+
AuthMethod: azureCreds.AuthMethod,
280281
HomeAccountID: azureCreds.HomeAccountID,
281282
ClientID: azureCreds.ClientID,
282283
IsServicePrincipal: azureCreds.IsServicePrincipal,
@@ -301,6 +302,9 @@ type msalCacheUpdate struct {
301302
KeyVaultExpiration string
302303
UserOID string
303304
TenantID string
305+
// AuthMethod is the AzureCredentials.AuthMethod that minted these tokens;
306+
// selects the MSAL cache account_source label.
307+
AuthMethod string
304308
// HomeAccountID is MSAL's "{home-oid}.{home-tenant-id}" for the authenticated
305309
// account, when the provider received it from MSAL. For guest (B2B) users this
306310
// differs from "{UserOID}.{TenantID}" and MUST be used for the cache Account
@@ -398,6 +402,16 @@ type msalIdentifiers struct {
398402
realm string
399403
}
400404

405+
// accountSourceForAuthMethod returns the MSAL cache account_source label for an
406+
// auth method, mirroring what az itself records: "authorization_code" for the
407+
// interactive browser flow, "device_code" otherwise.
408+
func accountSourceForAuthMethod(authMethod string) string {
409+
if authMethod == types.AzureAuthMethodInteractive {
410+
return "authorization_code"
411+
}
412+
return "device_code"
413+
}
414+
401415
// addUserAccountAndTokens adds account entry and tokens for user authentication.
402416
func addUserAccountAndTokens(sections *msalCacheSections, params *msalCacheUpdate) {
403417
// Prefer MSAL's own home account ID: for guest (B2B) users the home tenant
@@ -426,7 +440,7 @@ func addUserAccountAndTokens(sections *msalCacheSections, params *msalCacheUpdat
426440
"local_account_id": params.UserOID,
427441
"username": extractUsernameOrFallback(params.AccessToken),
428442
"authority_type": "MSSTS",
429-
"account_source": "device_code",
443+
"account_source": accountSourceForAuthMethod(params.AuthMethod),
430444
}
431445
sections.account[accountKey] = accountEntry
432446

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
package azure
2+
3+
import (
4+
"testing"
5+
6+
"github.com/stretchr/testify/assert"
7+
8+
"github.com/cloudposse/atmos/pkg/auth/types"
9+
)
10+
11+
func TestAccountSourceForAuthMethod(t *testing.T) {
12+
// The label mirrors what az itself records per flow.
13+
tests := []struct {
14+
name string
15+
authMethod string
16+
want string
17+
}{
18+
{
19+
name: "interactive maps to authorization_code",
20+
authMethod: types.AzureAuthMethodInteractive,
21+
want: "authorization_code",
22+
},
23+
{
24+
name: "device code keeps device_code",
25+
authMethod: types.AzureAuthMethodDeviceCode,
26+
want: "device_code",
27+
},
28+
{
29+
name: "unset defaults to device_code",
30+
authMethod: "",
31+
want: "device_code",
32+
},
33+
}
34+
35+
for _, tt := range tests {
36+
t.Run(tt.name, func(t *testing.T) {
37+
assert.Equal(t, tt.want, accountSourceForAuthMethod(tt.authMethod))
38+
})
39+
}
40+
}

pkg/auth/factory/factory.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,8 @@ func NewProvider(name string, config *schema.Provider) (types.Provider, error) {
110110
return azureProviders.NewCLIProvider(name, config)
111111
case "azure/device-code":
112112
return azureProviders.NewDeviceCodeProvider(name, config)
113+
case "azure/interactive":
114+
return azureProviders.NewInteractiveProvider(name, config)
113115
case "azure/oidc":
114116
return azureProviders.NewOIDCProvider(name, config)
115117
case "github/oidc":

pkg/auth/factory/factory_test.go

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,17 @@ func TestNewProvider_Factory(t *testing.T) {
5151
},
5252
expectError: false,
5353
},
54+
{
55+
name: "azure-interactive-valid",
56+
providerName: "azure-interactive",
57+
config: &schema.Provider{
58+
Kind: "azure/interactive",
59+
Spec: map[string]interface{}{
60+
"tenant_id": "test-tenant-id",
61+
},
62+
},
63+
expectError: false,
64+
},
5465
{
5566
name: "azure-oidc-valid",
5667
providerName: "azure-oidc",

pkg/auth/providers/azure/device_code.go

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,10 @@ type deviceCodeProvider struct {
4141
cloudEnv *azureCloud.CloudEnvironment // Azure cloud environment (public, usgovernment, china).
4242
cacheStorage CacheStorage
4343
realm string // Credential isolation realm set by auth manager.
44+
// authMethod is the AzureCredentials.AuthMethod value this provider mints
45+
// (device_code, or interactive when embedded by interactiveProvider). It also
46+
// selects the MSAL cache account_source label.
47+
authMethod string
4448
}
4549

4650
// deviceCodeConfig holds extracted Azure configuration from provider spec.
@@ -112,6 +116,7 @@ func NewDeviceCodeProvider(name string, config *schema.Provider) (*deviceCodePro
112116
clientID: cfg.ClientID,
113117
cloudEnv: azureCloud.GetCloudEnvironment(cfg.CloudEnvironment),
114118
cacheStorage: &defaultCacheStorage{},
119+
authMethod: authTypes.AzureAuthMethodDeviceCode,
115120
}, nil
116121
}
117122

@@ -438,6 +443,25 @@ func (p *deviceCodeProvider) captureHomeAccountID(accounts []public.Account, res
438443
}
439444
}
440445

446+
// credentialsAuthMethod returns the AuthMethod this provider mints, defaulting
447+
// to device_code for instances constructed without one (e.g. in tests).
448+
func (p *deviceCodeProvider) credentialsAuthMethod() string {
449+
if p.authMethod == "" {
450+
return authTypes.AzureAuthMethodDeviceCode
451+
}
452+
return p.authMethod
453+
}
454+
455+
// accountSource returns the MSAL cache account_source label matching this
456+
// provider's flow, mirroring what az itself records: "authorization_code" for
457+
// the interactive browser flow, "device_code" otherwise.
458+
func (p *deviceCodeProvider) accountSource() string {
459+
if p.authMethod == authTypes.AzureAuthMethodInteractive {
460+
return "authorization_code"
461+
}
462+
return "device_code"
463+
}
464+
441465
// createCredentials creates Azure credentials from acquired tokens.
442466
// Currently returns nil error but signature matches GetCredentials interface.
443467
//
@@ -451,7 +475,7 @@ func (p *deviceCodeProvider) createCredentials(tokens *tokenAcquisitionResult) (
451475
SubscriptionID: p.subscriptionID,
452476
Location: p.location,
453477
CloudEnvironment: p.cloudEnv.Name, // Propagate cloud environment for MSAL cache.
454-
AuthMethod: authTypes.AzureAuthMethodDeviceCode,
478+
AuthMethod: p.credentialsAuthMethod(),
455479
HomeAccountID: tokens.homeAccountID,
456480
}
457481

pkg/auth/providers/azure/device_code_cache.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -321,7 +321,7 @@ func (p *deviceCodeProvider) populateCLICacheWithTokens(
321321
"local_account_id": userOID,
322322
"username": username,
323323
"authority_type": "MSSTS",
324-
"account_source": "device_code",
324+
"account_source": p.accountSource(),
325325
}
326326
accountSection[accountKey] = accountEntry
327327
log.Debug("Added Account entry to MSAL cache", azureCloud.LogFieldKey, accountKey, "username", username)

0 commit comments

Comments
 (0)