@@ -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.
124134func (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.
0 commit comments