diff --git a/CHANGELOG.md b/CHANGELOG.md index a0059e780..e467fa5ae 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,9 @@ ## Release (2026-mm-dd) -- `core`: [v0.21.1](core/CHANGELOG.md#v0211) - - **Dependencies**: Bump `github.com/golang-jwt/jwt/v5` from `v5.3.0` to `v5.3.1` +- `core`: + - [v0.22.0](core/CHANGELOG.md#v0220) + - **Feature:** Support Azure DevOps OIDC adapter + - [v0.21.1](core/CHANGELOG.md#v0211) + - **Dependencies**: Bump `github.com/golang-jwt/jwt/v5` from `v5.3.0` to `v5.3.1` - `alb`: - [v0.9.3](services/alb/CHANGELOG.md#v093) - Bump STACKIT SDK core module from `v0.21.0` to `v0.21.1` 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 + } +}