diff --git a/.gitignore b/.gitignore index e517c14018..c66df54208 100644 --- a/.gitignore +++ b/.gitignore @@ -18,4 +18,4 @@ tooling/bin **/__pycache__ **/charts/*.tgz /vendor/ -**/*.sha256 \ No newline at end of file +**/*.sha256.env diff --git a/demo/bicep/external_auth.bicep b/demo/bicep/external_auth.bicep new file mode 100644 index 0000000000..5380068b3f --- /dev/null +++ b/demo/bicep/external_auth.bicep @@ -0,0 +1,83 @@ +param name string +param location string = resourceGroup().location +param currentTime string = utcNow() +param consoleCallbackUrl string // e.g., https://console-openshift-console.apps.example.com/auth/callback + +resource script 'Microsoft.Resources/deploymentScripts@2019-10-01-preview' = { + name: name + location: location + kind: 'AzurePowerShell' + identity: { + type: 'UserAssigned' + userAssignedIdentities: { + '${resourceId("app-reg-automation", "Microsoft.ManagedIdentity/userAssignedIdentities", "AppRegCreator")}': {} + } + } + properties: { + azPowerShellVersion: '5.0' + arguments: '-resourceName "${name}" -consoleCallback "${consoleCallbackUrl}"' + scriptContent: ''' + param([string] $resourceName, [string] $consoleCallback) + + $token = (Get-AzAccessToken -ResourceUrl https://graph.microsoft.com).Token + $headers = @{'Content-Type' = 'application/json'; 'Authorization' = 'Bearer ' + $token} + + $template = @{ + displayName = $resourceName + signInAudience = "AzureADMyOrg" + web = @{ + redirectUris = @($consoleCallback) + } + requiredResourceAccess = @( + @{ + resourceAppId = "00000003-0000-0000-c000-000000000000" + resourceAccess = @( + @{ + id = "e1fe6dd8-ba31-4d61-89e7-88639da4683d" + type = "Scope" + } + ) + } + ) + } + + # Upsert App registration + $app = (Invoke-RestMethod -Method Get -Headers $headers -Uri "https://graph.microsoft.com/beta/applications?filter=displayName eq '$($resourceName)'").value + $principal = @{} + if ($app) { + $ignore = Invoke-RestMethod -Method Patch -Headers $headers -Uri "https://graph.microsoft.com/beta/applications/$($app.id)" -Body ($template | ConvertTo-Json -Depth 10) + $principal = (Invoke-RestMethod -Method Get -Headers $headers -Uri "https://graph.microsoft.com/beta/servicePrincipals?filter=appId eq '$($app.appId)'").value + } else { + $app = (Invoke-RestMethod -Method Post -Headers $headers -Uri "https://graph.microsoft.com/beta/applications" -Body ($template | ConvertTo-Json -Depth 10)) + $principal = Invoke-RestMethod -Method POST -Headers $headers -Uri "https://graph.microsoft.com/beta/servicePrincipals" -Body (@{ "appId" = $app.appId } | ConvertTo-Json) + } + + # Regenerate client secret + $app = (Invoke-RestMethod -Method Get -Headers $headers -Uri "https://graph.microsoft.com/beta/applications/$($app.id)") + foreach ($password in $app.passwordCredentials) { + $body = @{ "keyId" = $password.keyId } + $ignore = Invoke-RestMethod -Method POST -Headers $headers -Uri "https://graph.microsoft.com/beta/applications/$($app.id)/removePassword" -Body ($body | ConvertTo-Json) + } + + $body = @{ "passwordCredential" = @{ "displayName"= "Client Secret" } } + $secret = (Invoke-RestMethod -Method POST -Headers $headers -Uri "https://graph.microsoft.com/beta/applications/$($app.id)/addPassword" -Body ($body | ConvertTo-Json)).secretText + + $DeploymentScriptOutputs = @{ + objectId = $app.id + clientId = $app.appId + clientSecret = $secret + principalId = $principal.id + redirectUri = $consoleCallback + } + ''' + cleanupPreference: 'OnSuccess' + retentionInterval: 'P1D' + forceUpdateTag: currentTime + } +} + +output objectId string = script.properties.outputs.objectId +output clientId string = script.properties.outputs.clientId +output clientSecret string = script.properties.outputs.clientSecret +output principalId string = script.properties.outputs.principalId +output redirectUri string = script.properties.outputs.redirectUri diff --git a/test/e2e/.env b/test/e2e/.env new file mode 100644 index 0000000000..89e758d496 --- /dev/null +++ b/test/e2e/.env @@ -0,0 +1,7 @@ +export LOCAL_DEVELOPMENT=true +export AROHCP_ENV=development +export CUSTOMER_SUBSCRIPTION=1d3378d3-5a3f-4712-85a1-2485495dfc4b +export RESOURCE_GROUP=mfreer-rg-03 +export CLUSTER_NAME=mfreer +export OIDC_ISSUER_URL=https://login.microsoftonline.com/fa5d3dd8-b8ec-4407-a55c-ced639f1c8c5/v2.0 +export OIDC_CLIENT_ID=3bc2c42a-4198-42c1-a052-1a505faf8a90 diff --git a/test/e2e/external_auth_test.go b/test/e2e/external_auth_test.go new file mode 100644 index 0000000000..ce49492634 --- /dev/null +++ b/test/e2e/external_auth_test.go @@ -0,0 +1,118 @@ +// Copyright 2025 Microsoft Corporation +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package e2e + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "time" + + . "github.com/onsi/ginkgo/v2" + . "github.com/onsi/gomega" +) + +var _ = Describe("ExternalAuth via RP Frontend", Label("external-auth"), func() { + var ( + baseURL = "http://localhost:8443" + subscriptionID string + resourceGroup string + clusterName string + providerID = "e2e-entra" + apiVersion = "2024-06-10-preview" + issuerURL = "https://login.microsoftonline.com//v2.0" + clientID = "" + ) + + BeforeEach(func() { + subscriptionID = os.Getenv("CUSTOMER_SUBSCRIPTION") + Expect(subscriptionID).ToNot(BeEmpty(), "CUSTOMER_SUBSCRIPTION must be set") + + resourceGroup = os.Getenv("RESOURCE_GROUP") + Expect(resourceGroup).ToNot(BeEmpty(), "RESOURCE_GROUP must be set") + + clusterName = os.Getenv("CLUSTER_NAME") + Expect(clusterName).ToNot(BeEmpty(), "CLUSTER_NAME must be set") + + if val := os.Getenv("OIDC_ISSUER_URL"); val != "" { + issuerURL = val + } + if val := os.Getenv("OIDC_CLIENT_ID"); val != "" { + clientID = val + } + }) + + It("should create, get, list and delete an ExternalAuth config via RP API", func() { + resourceURL := fmt.Sprintf("%s/subscriptions/%s/resourceGroups/%s/providers/Microsoft.RedHatOpenShift/hcpOpenShiftClusters/%s/externalAuths/%s?api-version=%s", + baseURL, subscriptionID, resourceGroup, clusterName, providerID, apiVersion) + + payload := map[string]interface{}{ + "properties": map[string]interface{}{ + "issuer": map[string]interface{}{ + "url": issuerURL, + "audiences": []string{clientID}, + }, + "claimMappings": map[string]interface{}{ + "username": map[string]interface{}{"claim": "email"}, + "groups": map[string]interface{}{"claim": "groups"}, + }, + }, + } + + By("Creating ExternalAuth config") + body, _ := json.Marshal(payload) + req, _ := http.NewRequest("PUT", resourceURL, bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Ms-Identity-Url", "https://dummy.identity.azure.net") + req.Header.Set("X-Ms-Arm-Resource-System-Data", + fmt.Sprintf(`{"createdBy": "e2e","createdByType":"User","createdAt":"%s"}`, time.Now().UTC().Format(time.RFC3339))) + + resp, err := http.DefaultClient.Do(req) + Expect(err).ToNot(HaveOccurred()) + defer resp.Body.Close() + respBody, _ := io.ReadAll(resp.Body) + fmt.Fprintf(GinkgoWriter, "PUT response body: %s\n", respBody) + Expect(resp.StatusCode).To(Equal(http.StatusOK)) + + By("Fetching the created ExternalAuth config") + Eventually(func() int { + respGet, err := http.Get(resourceURL) + Expect(err).ToNot(HaveOccurred()) + defer respGet.Body.Close() + return respGet.StatusCode + }, 10*time.Second, 1*time.Second).Should(Equal(http.StatusOK)) + + By("Listing ExternalAuth configs") + listURL := fmt.Sprintf("%s/subscriptions/%s/resourceGroups/%s/providers/Microsoft.RedHatOpenShift/hcpOpenShiftClusters/%s/externalAuths?api-version=%s", + baseURL, subscriptionID, resourceGroup, clusterName, apiVersion) + respList, err := http.Get(listURL) + Expect(err).ToNot(HaveOccurred()) + Expect(respList.StatusCode).To(Equal(http.StatusOK)) + defer respList.Body.Close() + + By("Deleting the ExternalAuth config") + reqDel, _ := http.NewRequest("DELETE", resourceURL, nil) + reqDel.Header.Set("X-Ms-Identity-Url", "https://dummy.identity.azure.net") + reqDel.Header.Set("X-Ms-Arm-Resource-System-Data", + fmt.Sprintf(`{"createdBy": "e2e","createdByType":"User","createdAt":"%s"}`, time.Now().UTC().Format(time.RFC3339))) + respDel, err := http.DefaultClient.Do(reqDel) + Expect(err).ToNot(HaveOccurred()) + Expect(respDel.StatusCode).To(Equal(http.StatusNoContent)) + defer respDel.Body.Close() + }) +})