diff --git a/CHANGELOG.md b/CHANGELOG.md index 8feabb930..1ebe3f953 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,7 @@ ## Release (2026-DD-MM) +- `core`: [v0.22.0](core/CHANGELOG.md#v0220) + - **Feature:** Support Azure DevOps OIDC adapter - `alb`: [v0.10.0](services/alb/CHANGELOG.md#v0100) - **Feature:** Add new field `AltPort` to `ActiveHealthCheck` - **Feature:** Add new field `Tls` to `HttpHealthCheck` diff --git a/core/CHANGELOG.md b/core/CHANGELOG.md index 6b2630971..c66211445 100644 --- a/core/CHANGELOG.md +++ b/core/CHANGELOG.md @@ -1,3 +1,6 @@ +## v0.22.0 +- **Feature:** Support Azure DevOps OIDC adapter + ## v0.21.1 - **Dependencies:** Bump `github.com/golang-jwt/jwt/v5` from `v5.3.0` to `v5.3.1` diff --git a/core/VERSION b/core/VERSION index 40c85001a..4f2794371 100644 --- a/core/VERSION +++ b/core/VERSION @@ -1 +1 @@ -v0.21.1 +v0.22.0 diff --git a/core/oidcadapters/azuredevops.go b/core/oidcadapters/azuredevops.go new file mode 100644 index 000000000..12617dbd2 --- /dev/null +++ b/core/oidcadapters/azuredevops.go @@ -0,0 +1,73 @@ +package oidcadapters + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" +) + +const ( + adoPipelineOIDCAPIVersion = "7.1" + adoAudience = "api://AzureADTokenExchange" +) + +func RequestAzureDevOpsOIDCToken(oidcRequestUrl, oidcRequestToken, serviceConnectionID string) OIDCTokenFunc { + return func(ctx context.Context) (string, error) { + req, err := http.NewRequestWithContext(ctx, http.MethodPost, oidcRequestUrl, http.NoBody) + if err != nil { + return "", fmt.Errorf("azureDevOpsAssertion: failed to build request: %w", err) + } + + query, err := url.ParseQuery(req.URL.RawQuery) + if err != nil { + return "", fmt.Errorf("azureDevOpsAssertion: cannot parse URL query") + } + + if query.Get("api-version") == "" { + query.Add("api-version", adoPipelineOIDCAPIVersion) + } + + if query.Get("serviceConnectionId") == "" && serviceConnectionID != "" { + query.Add("serviceConnectionId", serviceConnectionID) + } + + if query.Get("audience") == "" { + query.Set("audience", adoAudience) // Azure DevOps requires this specific audience for OIDC tokens + } + + req.URL.RawQuery = query.Encode() + + req.Header.Set("Accept", "application/json") + req.Header.Set("Authorization", fmt.Sprintf("Bearer %s", oidcRequestToken)) + req.Header.Set("Content-Type", "application/json") + + resp, err := http.DefaultClient.Do(req) + if err != nil { + return "", fmt.Errorf("azureDevOpsAssertion: cannot request token: %w", err) + } + + defer func() { + _ = resp.Body.Close() + }() + body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + if err != nil { + return "", fmt.Errorf("azureDevOpsAssertion: cannot parse response: %w", err) + } + + if c := resp.StatusCode; c < 200 || c > 299 { + return "", fmt.Errorf("azureDevOpsAssertion: received HTTP status %d with response: %s", resp.StatusCode, body) + } + + var tokenRes struct { + Value *string `json:"oidcToken"` + } + if err := json.Unmarshal(body, &tokenRes); err != nil || tokenRes.Value == nil { + return "", fmt.Errorf("azureDevOpsAssertion: cannot unmarshal response: %w", err) + } + + return *tokenRes.Value, nil + } +}