Skip to content

Commit 5c42faa

Browse files
edwardrfedw-defang
andauthored
Support azure oidc using github token (#2113)
Co-authored-by: Edward J <edw@defang.io>
1 parent 6ba73aa commit 5c42faa

2 files changed

Lines changed: 124 additions & 7 deletions

File tree

src/pkg/clouds/azure/login.go

Lines changed: 61 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -17,6 +17,7 @@ import (
1717
"github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armsubscriptions"
1818
"github.com/AzureAD/microsoft-authentication-library-for-go/apps/cache"
1919
"github.com/AzureAD/microsoft-authentication-library-for-go/apps/public"
20+
"github.com/DefangLabs/defang/src/pkg/github"
2021
"github.com/DefangLabs/defang/src/pkg/term"
2122
"github.com/DefangLabs/defang/src/pkg/tokenstore"
2223
)
@@ -33,6 +34,11 @@ const (
3334
// msalCacheKey is the TokenStore key holding MSAL's serialized cache blob
3435
// (one blob per defang installation covers all accounts MSAL tracks).
3536
msalCacheKey = "azure-msal-cache"
37+
// githubFederationAudience is the audience Entra requires on a GitHub
38+
// Actions OIDC token to honor a federated identity credential. The FIC
39+
// provisioned by the Defang portal's Azure wizard explicitly trusts this
40+
// audience; matches the value the `azure/login@v2` GitHub Action uses.
41+
githubFederationAudience = "api://AzureADTokenExchange"
3642
)
3743

3844
// defangMSALCache adapts defang's file-based TokenStore to MSAL's
@@ -108,6 +114,10 @@ func (c *msalCred) GetToken(ctx context.Context, opts policy.TokenRequestOptions
108114
}
109115

110116
// Authenticate sets up Azure credentials for the session in order of preference:
117+
// 0. GitHub Actions OIDC — when ACTIONS_ID_TOKEN_REQUEST_URL is set and the
118+
// stack file's AZURE_CLIENT_ID + AZURE_TENANT_ID identify a federated
119+
// UAMI, exchange the GitHub OIDC token for an ARM token. Mirrors the GCP
120+
// path in pkg/clouds/gcp/login.go::findGithubCredentials.
111121
// 1. Existing default Azure credentials — env vars (AZURE_TENANT_ID/CLIENT_ID/
112122
// CLIENT_SECRET), managed identity, workload identity, an `az login`
113123
// session picked up via AzureCLICredential, etc.
@@ -118,9 +128,9 @@ func (c *msalCred) GetToken(ctx context.Context, opts policy.TokenRequestOptions
118128
// On success the refresh token is written to the cache so step 2 works
119129
// on the next invocation.
120130
//
121-
// On success a.Cred is populated with an msalCred (for path 2/3) or a
122-
// DefaultAzureCredential wrapper (path 1). Both honor per-scope GetToken
123-
// requests from the Azure SDK.
131+
// On success a.Cred is populated with an msalCred (for path 2/3), a
132+
// ClientAssertionCredential (path 0), or a DefaultAzureCredential wrapper
133+
// (path 1). All honor per-scope GetToken requests from the Azure SDK.
124134
func (a *Azure) Authenticate(ctx context.Context, interactive bool) error {
125135
if a.SubscriptionID == "" {
126136
a.SubscriptionID = os.Getenv("AZURE_SUBSCRIPTION_ID")
@@ -129,6 +139,19 @@ func (a *Azure) Authenticate(ctx context.Context, interactive bool) error {
129139
return errors.New("AZURE_SUBSCRIPTION_ID is required for Azure login")
130140
}
131141

142+
// 0. GitHub Actions OIDC (federated identity credential).
143+
term.Debug("checking GitHub Actions OIDC credentials...")
144+
if cred, err := a.tryGithubOIDC(ctx); err != nil {
145+
if ctx.Err() != nil {
146+
return ctx.Err()
147+
}
148+
term.Debugf("GitHub OIDC credential invalid: %v", err)
149+
} else if cred != nil {
150+
term.Debug("authenticated via GitHub Actions OIDC")
151+
a.Cred = cred
152+
return nil
153+
}
154+
132155
// 1. DefaultAzureCredential (az cli session, env vars, managed identity, …).
133156
term.Debug("checking default Azure credentials...")
134157
if cred, err := a.tryDefaultCredential(ctx); err != nil {
@@ -230,6 +253,41 @@ func (a *Azure) trySilentMSAL(ctx context.Context, client public.Client, tenant
230253
return nil, nil
231254
}
232255

256+
// tryGithubOIDC builds a credential that federates the GitHub Actions
257+
// workflow's OIDC token into an Azure ARM token. Equivalent to running an
258+
// `azure/login@v2` step, but driven entirely from env vars in the Defang
259+
// stack file (AZURE_CLIENT_ID, AZURE_TENANT_ID) plus the standard GitHub
260+
// Actions OIDC plumbing (ACTIONS_ID_TOKEN_REQUEST_URL / _TOKEN).
261+
//
262+
// Returns (nil, nil) silently when not running in GitHub Actions OIDC, or
263+
// when AZURE_CLIENT_ID / AZURE_TENANT_ID aren't set — so Authenticate falls
264+
// through to the next credential option. Returns an error only when env
265+
// vars are present but the resulting credential fails to mint an ARM token
266+
// (e.g., the FIC's repo:branch subject doesn't match the current run).
267+
func (a *Azure) tryGithubOIDC(ctx context.Context) (azcore.TokenCredential, error) {
268+
if os.Getenv("ACTIONS_ID_TOKEN_REQUEST_URL") == "" {
269+
return nil, nil
270+
}
271+
clientID := os.Getenv("AZURE_CLIENT_ID")
272+
tenantID := os.Getenv("AZURE_TENANT_ID")
273+
if clientID == "" || tenantID == "" {
274+
return nil, nil
275+
}
276+
term.Debugf("AZURE_CLIENT_ID=%q AZURE_TENANT_ID=%q — attempting GitHub-OIDC federation", clientID, tenantID)
277+
278+
getAssertion := func(ctx context.Context) (string, error) {
279+
return github.GetIdToken(ctx, githubFederationAudience)
280+
}
281+
cred, err := azidentity.NewClientAssertionCredential(tenantID, clientID, getAssertion, nil)
282+
if err != nil {
283+
return nil, fmt.Errorf("building GitHub-OIDC Azure credential: %w", err)
284+
}
285+
if err := testAzureCredential(ctx, a.SubscriptionID, cred); err != nil {
286+
return nil, err
287+
}
288+
return cred, nil
289+
}
290+
233291
// tryDefaultCredential constructs a DefaultAzureCredential and tests it
234292
// against the subscription. Returns (nil, nil) when the cred builds but
235293
// fails the permission check; returns (cred, nil) when it works.

src/pkg/clouds/azure/login_test.go

Lines changed: 63 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,20 @@ import (
77
"time"
88
)
99

10+
// unsetEnv removes an env var for the duration of t. Unlike t.Setenv("", ""),
11+
// the variable is fully unset so callers using LookupEnv see ok=false.
12+
func unsetEnv(t *testing.T, key string) {
13+
t.Helper()
14+
if v, ok := os.LookupEnv(key); ok {
15+
_ = os.Unsetenv(key)
16+
t.Cleanup(func() { _ = os.Setenv(key, v) }) //nolint:usetesting
17+
}
18+
}
19+
1020
func TestAuthenticateMissingSubscriptionID(t *testing.T) {
1121
// Fully unset AZURE_SUBSCRIPTION_ID for the duration of this test —
1222
// t.Setenv("", ...) would leave it set-but-empty and LookupEnv returns true.
13-
if v, ok := os.LookupEnv("AZURE_SUBSCRIPTION_ID"); ok {
14-
_ = os.Unsetenv("AZURE_SUBSCRIPTION_ID")
15-
t.Cleanup(func() { _ = os.Setenv("AZURE_SUBSCRIPTION_ID", v) }) //nolint:usetesting
16-
}
23+
unsetEnv(t, "AZURE_SUBSCRIPTION_ID")
1724

1825
a := &Azure{}
1926
if err := a.Authenticate(context.Background(), false); err == nil {
@@ -24,6 +31,58 @@ func TestAuthenticateMissingSubscriptionID(t *testing.T) {
2431
}
2532
}
2633

34+
func TestTryGithubOIDC_NotInGithubActions(t *testing.T) {
35+
// Outside a GH Actions runner the function must no-op (return nil, nil) so
36+
// Authenticate falls through to DefaultAzureCredential. ACTIONS_ID_TOKEN_
37+
// REQUEST_URL is the canonical "we're in Actions" sentinel.
38+
unsetEnv(t, "ACTIONS_ID_TOKEN_REQUEST_URL")
39+
t.Setenv("AZURE_CLIENT_ID", "client-id")
40+
t.Setenv("AZURE_TENANT_ID", "tenant-id")
41+
42+
a := &Azure{SubscriptionID: "sub-id"}
43+
cred, err := a.tryGithubOIDC(context.Background())
44+
if err != nil {
45+
t.Fatalf("expected nil error when not in Actions, got %v", err)
46+
}
47+
if cred != nil {
48+
t.Error("expected nil credential when not in Actions")
49+
}
50+
}
51+
52+
func TestTryGithubOIDC_MissingClientID(t *testing.T) {
53+
// Even when ACTIONS_ID_TOKEN_REQUEST_URL is set, AZURE_CLIENT_ID is required
54+
// to know which UAMI to federate as. Without it, fall through silently.
55+
t.Setenv("ACTIONS_ID_TOKEN_REQUEST_URL", "https://example.invalid")
56+
unsetEnv(t, "AZURE_CLIENT_ID")
57+
t.Setenv("AZURE_TENANT_ID", "tenant-id")
58+
59+
a := &Azure{SubscriptionID: "sub-id"}
60+
cred, err := a.tryGithubOIDC(context.Background())
61+
if err != nil {
62+
t.Fatalf("expected nil error when AZURE_CLIENT_ID is missing, got %v", err)
63+
}
64+
if cred != nil {
65+
t.Error("expected nil credential when AZURE_CLIENT_ID is missing")
66+
}
67+
}
68+
69+
func TestTryGithubOIDC_MissingTenantID(t *testing.T) {
70+
// AZURE_TENANT_ID identifies which Entra tenant to exchange the OIDC
71+
// token at; without it the federation has no destination. Fall through silently.
72+
t.Setenv("ACTIONS_ID_TOKEN_REQUEST_URL", "https://example.invalid")
73+
t.Setenv("AZURE_CLIENT_ID", "client-id")
74+
unsetEnv(t, "AZURE_TENANT_ID")
75+
76+
a := &Azure{SubscriptionID: "sub-id"}
77+
cred, err := a.tryGithubOIDC(context.Background())
78+
if err != nil {
79+
t.Fatalf("expected nil error when AZURE_TENANT_ID is missing, got %v", err)
80+
}
81+
if cred != nil {
82+
t.Error("expected nil credential when AZURE_TENANT_ID is missing")
83+
}
84+
}
85+
2786
func TestAuthenticateNonInteractiveFailsWithInvalidSubscription(t *testing.T) {
2887
// An unknown subscription ID: the test call to ARM fails (either because
2988
// the subscription doesn't exist or because the caller has no credentials

0 commit comments

Comments
 (0)