Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
resource "okta_app_saml" "test" {
label = "testAcc_replace_with_uuid"
sso_url = "http://google.com"
recipient = "http://here.com"
destination = "http://its-about-the-journey.com"
audience = "http://audience.com"
subject_name_id_template = "$${user.userName}"
subject_name_id_format = "urn:oasis:names:tc:SAML:1.1:nameid-format:emailAddress"
response_signed = true
signature_algorithm = "RSA_SHA256"
digest_algorithm = "SHA256"
honor_force_authn = false
authn_context_class_ref = "urn:oasis:names:tc:SAML:2.0:ac:classes:PasswordProtectedTransport"
single_logout_issuer = "https://dunshire.okta.com"
single_logout_url = "https://dunshire.okta.com/logout"
single_logout_certificate = "MIIFnDCCA4QCCQDBSLbiON2T1zANBgkqhkiG9w0BAQsFADCBjzELMAkGA1UEBhMCVVMxDjAMBgNV\r\nBAgMBU1haW5lMRAwDgYDVQQHDAdDYXJpYm91MRcwFQYDVQQKDA5Tbm93bWFrZXJzIEluYzEUMBIG\r\nA1UECwwLRW5naW5lZXJpbmcxDTALBgNVBAMMBFNub3cxIDAeBgkqhkiG9w0BCQEWEWVtYWlsQGV4\r\nYW1wbGUuY29tMB4XDTIwMTIwMzIyNDY0M1oXDTMwMTIwMTIyNDY0M1owgY8xCzAJBgNVBAYTAlVT\r\nMQ4wDAYDVQQIDAVNYWluZTEQMA4GA1UEBwwHQ2FyaWJvdTEXMBUGA1UECgwOU25vd21ha2VycyBJ\r\nbmMxFDASBgNVBAsMC0VuZ2luZWVyaW5nMQ0wCwYDVQQDDARTbm93MSAwHgYJKoZIhvcNAQkBFhFl\r\nbWFpbEBleGFtcGxlLmNvbTCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBANMmWDjXPdoa\r\nPyzIENqeY9njLan2FqCbQPSestWUUcb6NhDsJVGSQ7XR+ozQA5TaJzbP7cAJUj8vCcbqMZsgOQAu\r\nO/pzYyQEKptLmrGvPn7xkJ1A1xLkp2NY18cpDTeUPueJUoidZ9EJwEuyUZIktzxNNU1pA1lGijiu\r\n2XNxs9d9JR/hm3tCu9Im8qLVB4JtX80YUa6QtlRjWR/H8a373AYCOASdoB3c57fIPD8ATDNy2w/c\r\nfCVGiyKDMFB+GA/WTsZpOP3iohRp8ltAncSuzypcztb2iE+jijtTsiC9kUA2abAJqqpoCJubNShi\r\nVff4822czpziS44MV2guC9wANi8u3Uyl5MKsU95j01jzadKRP5S+2f0K+n8n4UoV9fnqZFyuGAKd\r\nCJi9K6NlSAP+TgPe/JP9FOSuxQOHWJfmdLHdJD+evoKi9E55sr5lRFK0xU1Fj5Ld7zjC0pXPhtJf\r\nsgjEZzD433AsHnRzvRT1KSNCPkLYomznZo5n9rWYgCQ8HcytlQDTesmKE+s05E/VSWNtH84XdDrt\r\nieXwfwhHfaABSu+WjZYxi9CXdFCSvXhsgufUcK4FbYAHl/ga/cJxZc52yFC7Pcq0u9O2BSCjYPdQ\r\nDAHs9dhT1RhwVLM8RmoAzgxyyzau0gxnAlgSBD9FMW6dXqIHIp8yAAg9cRXhYRTNAgMBAAEwDQYJ\r\nKoZIhvcNAQELBQADggIBADofEC1SvG8qa7pmKCjB/E9Sxhk3mvUO9Gq43xzwVb721Ng3VYf4vGU3\r\nwLUwJeLt0wggnj26NJweN5T3q9T8UMxZhHSWvttEU3+S1nArRB0beti716HSlOCDx4wTmBu/D1MG\r\nt/kZYFJw+zuzvAcbYct2pK69AQhD8xAIbQvqADJI7cCK3yRry+aWtppc58P81KYabUlCfFXfhJ9E\r\nP72ffN4jVHpX3lxxYh7FKAdiKbY2FYzjsc7RdgKI1R3iAAZUCGBTvezNzaetGzTUjjl/g1tcVYij\r\nltH9ZOQBPlUMI88lxUxqgRTerpPmAJH00CACx4JFiZrweLM1trZyy06wNDQgLrqHr3EOagBF/O2h\r\nhfTehNdVr6iq3YhKWBo4/+RL0RCzHMh4u86VbDDnDn4Y6HzLuyIAtBFoikoKM6UHTOa0Pqv2bBr5\r\nwbkRkVUxl9yJJw/HmTCdfnsM9dTOJUKzEglnGF2184Gg+qJDZB6fSf0EAO1F6sTqiSswl+uHQZiy\r\nDaZzyU7Gg5seKOZ20zTRaX3Ihj9Zij/ORnrARE7eM/usKMECp+7syUwAUKxDCZkGiUdskmOhhBGL\r\nJtbyK3F2UvoJoLsm3pIcvMak9KwMjSTGJB47ABUP1+w+zGcNk0D5Co3IJ6QekiLfWJyQ+kKsWLKt\r\nzOYQQatrnBagM7MI2/T4\r\n"

attribute_statements {
type = "GROUP"
name = "groups"
filter_type = "REGEX"
filter_value = ".*"
}
}

data "okta_app_signon_policy" "test" {
app_id = okta_app_saml.test.id
}

resource "okta_app_signon_policy_rules" "test_chains_misaligned" {
policy_id = data.okta_app_signon_policy.test.id

rule {
name = "MisalignedKeys-testAcc_replace_with_uuid"
priority = 1
status = "ACTIVE"
access = "ALLOW"
factor_mode = "2FA"
type = "AUTH_METHOD_CHAIN"
chains = [
jsonencode(
{
"authenticationMethods" : [
{
"key" : "google_otp",
"method" : "otp"
},
{
"key" : "okta_verify",
"userVerification" : "OPTIONAL",
"method" : "push"
},
{
"key" : "okta_verify",
"method" : "totp"
},
{
"key" : "okta_verify",
"userVerification" : "OPTIONAL",
"method" : "signed_nonce"
}
],
"reauthenticateIn" : "PT0S",
"next" : [
{
"authenticationMethods" : [
{
"key" : "okta_password",
"method" : "password"
}
],
"reauthenticateIn" : "PT0S"
}
]
})
]
}
}



45 changes: 45 additions & 0 deletions okta/services/idaas/resource_okta_app_signon_policy_rules.go
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,48 @@ func (m reauthFrequencyModifier) PlanModifyString(ctx context.Context, req planm
}
}

// ChainsPlanModifier normalizes the JSON key order of each chains element during
// planning so the plan value always matches the post-apply canonical form.
type ChainsPlanModifier struct{}

func (m ChainsPlanModifier) Description(_ context.Context) string {
return "Normalizes JSON key order in chains elements to prevent inconsistent result after apply"
}

func (m ChainsPlanModifier) MarkdownDescription(ctx context.Context) string {
return m.Description(ctx)
}

func (m ChainsPlanModifier) PlanModifyList(ctx context.Context, req planmodifier.ListRequest, resp *planmodifier.ListResponse) {
if req.PlanValue.IsNull() || req.PlanValue.IsUnknown() {
return
}
var chainStrings []string
resp.Diagnostics.Append(req.PlanValue.ElementsAs(ctx, &chainStrings, false)...)
if resp.Diagnostics.HasError() {
return
}
normalized := make([]string, len(chainStrings))
for i, s := range chainStrings {
var raw map[string]interface{}
if err := json.Unmarshal([]byte(s), &raw); err != nil {
resp.Diagnostics.AddAttributeError(
req.Path,
"Invalid chains JSON",
fmt.Sprintf("chains[%d] is not valid JSON: %s", i, err),
)
return
}
b, _ := json.Marshal(raw)
normalized[i] = string(b)
}
listVal, diags := types.ListValueFrom(ctx, types.StringType, normalized)
resp.Diagnostics.Append(diags...)
if !resp.Diagnostics.HasError() {
resp.PlanValue = listVal
}
}

// ruleIndex provides efficient lookups for rules by name and ID.
type ruleIndex struct {
byName map[string]policyRuleModel
Expand Down Expand Up @@ -788,6 +830,9 @@ func (r *appSignOnPolicyRulesResource) buildRuleAttributes() map[string]schema.A
Optional: true,
ElementType: types.StringType,
Description: "List of authentication method chain objects as JSON-encoded strings. Use with `type = \"AUTH_METHOD_CHAIN\"` only.",
PlanModifiers: []planmodifier.List{
ChainsPlanModifier{},
},
},
"risk_score": schema.StringAttribute{
Optional: true,
Expand Down
107 changes: 107 additions & 0 deletions okta/services/idaas/resource_okta_app_signon_policy_rules_test.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
package idaas_test

import (
"context"
"fmt"
"testing"

"github.com/hashicorp/terraform-plugin-framework/resource/schema/planmodifier"
"github.com/hashicorp/terraform-plugin-framework/types"
"github.com/hashicorp/terraform-plugin-sdk/v2/helper/resource"
"github.com/okta/terraform-provider-okta/okta/acctest"
"github.com/okta/terraform-provider-okta/okta/resources"
Expand Down Expand Up @@ -227,6 +230,44 @@ func TestAccResourceOktaAppSignOnPolicyRules_chains(t *testing.T) {
})
}

// TestAccResourceOktaAppSignOnPolicyRules_chains_misaligned_keys verifies that
// chains with non-alphabetical JSON key ordering (e.g., userVerification before
// method) are normalized during plan, preventing "Provider produced inconsistent
// result after apply" errors. This regression test covers OKTA-1184047.
func TestAccResourceOktaAppSignOnPolicyRules_chains_misaligned_keys(t *testing.T) {
resourceName := fmt.Sprintf("%s.test_chains_misaligned", resources.OktaIDaaSAppSignOnPolicyRules)
mgr := newFixtureManager("resources", resources.OktaIDaaSAppSignOnPolicyRules, t.Name())
config := mgr.GetFixtures("chains_misaligned_keys.tf", t)
acctest.OktaResourceTest(t, resource.TestCase{
PreCheck: acctest.AccPreCheck(t),
ErrorCheck: testAccErrorChecks(t),
ProtoV5ProviderFactories: acctest.ProtoV5ProviderFactoriesForTestAcc(t),
CheckDestroy: checkAppSignOnPolicyRuleDestroy,
Steps: []resource.TestStep{
{
Config: config,
Check: resource.ComposeTestCheckFunc(
resource.TestCheckResourceAttrSet(resourceName, "id"),
resource.TestCheckResourceAttrSet(resourceName, "policy_id"),
resource.TestCheckResourceAttr(resourceName, "rule.#", "1"),
resource.TestCheckResourceAttrSet(resourceName, "rule.0.id"),
resource.TestCheckResourceAttr(resourceName, "rule.0.name", fmt.Sprintf("MisalignedKeys-testAcc_%s", mgr.SeedStr())),
resource.TestCheckResourceAttr(resourceName, "rule.0.chains.#", "1"),
// Verify the chain is stored
resource.TestCheckResourceAttrSet(resourceName, "rule.0.chains.0"),
),
},
{
// Idempotency check — this should succeed without "inconsistent result" error.
// Before the fix, this step would fail with:
// "Provider produced inconsistent result after apply"
Config: config,
PlanOnly: true,
},
},
})
}

// TestAccResourceOktaAppSignOnPolicyRules_keep_me_signed_in verifies that the
// keep_me_signed_in (KMSI / "Option to stay signed in") block on the plural
// resource round-trips correctly across multiple rules. The config defines four
Expand Down Expand Up @@ -318,3 +359,69 @@ func TestAccResourceOktaAppSignOnPolicyRules_keep_me_signed_in(t *testing.T) {
},
})
}

func TestChainsPlanModifier(t *testing.T) {
modifier := idaas.ChainsPlanModifier{}

tests := []struct {
name string
planValue string
expectedValue string
wantErr bool
}{
{
name: "already alphabetical",
planValue: `{"key":"okta_verify","method":"push","userVerification":"OPTIONAL"}`,
expectedValue: `{"key":"okta_verify","method":"push","userVerification":"OPTIONAL"}`,
},
{
name: "userVerification before method",
planValue: `{"key":"okta_verify","userVerification":"OPTIONAL","method":"push"}`,
expectedValue: `{"key":"okta_verify","method":"push","userVerification":"OPTIONAL"}`,
},
{
name: "complex nested non-alphabetical keys",
planValue: `{"authenticationMethods":[{"userVerification":"OPTIONAL","method":"push","key":"okta_verify"}],"reauthenticateIn":"PT0S","next":[]}`,
expectedValue: `{"authenticationMethods":[{"key":"okta_verify","method":"push","userVerification":"OPTIONAL"}],"next":[],"reauthenticateIn":"PT0S"}`,
},
{
name: "invalid JSON returns error diagnostic",
planValue: `not-valid-json`,
wantErr: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := context.Background()
listValue, diags := types.ListValueFrom(ctx, types.StringType, []string{tt.planValue})
if diags.HasError() {
t.Fatalf("failed to create list value: %v", diags)
}

req := planmodifier.ListRequest{PlanValue: listValue}
resp := &planmodifier.ListResponse{PlanValue: listValue}
modifier.PlanModifyList(ctx, req, resp)

if tt.wantErr {
if !resp.Diagnostics.HasError() {
t.Fatal("expected error diagnostic, got none")
}
return
}

if resp.Diagnostics.HasError() {
t.Fatalf("unexpected error: %v", resp.Diagnostics)
}

var result []string
resp.PlanValue.ElementsAs(ctx, &result, false)
if len(result) != 1 {
t.Fatalf("expected 1 element, got %d", len(result))
}
if result[0] != tt.expectedValue {
t.Errorf("got %s, want %s", result[0], tt.expectedValue)
}
})
}
}
Loading