From 13099d40a8a952046c7727e56b7864347b377570 Mon Sep 17 00:00:00 2001 From: Manmohan Shaw Date: Thu, 11 Jun 2026 21:34:43 +0530 Subject: [PATCH] fix(spec): add discriminator to IdentityProvider.protocol to fix SAML/OIDC field dropping (OKTA-1175444) The protocol field in IdentityProvider used a discriminator-less oneOf, causing the OpenAPI generator to produce a flat IdentityProviderProtocol class with wrong field types (OidcAlgorithms, IDVCredentials, IDVEndpoints, OidcSettings). On deserialization, SAML-specific fields (sso.url, trust.issuer, nameFormat, etc.) were silently dropped and OIDC-shaped endpoints were invented, corrupting IdP configurations on write-back. - Add IdentityProviderProtocol as a named base schema with discriminator (propertyName: type, mapping SAML2/OIDC/OAUTH2/MTLS/ID_PROOFING) - Convert ProtocolSaml, ProtocolOidc, ProtocolOAuth, ProtocolMtls, ProtocolIdVerification to extend the base via allOf - Generated code: ProtocolSaml/Oidc/etc. now extend IdentityProviderProtocol with correct protocol-specific field types; Jackson @JsonTypeInfo + @JsonSubTypes on the base class dispatches deserialization by type value - Add IdentityProviderProtocolDeserializerTest (14 tests) covering SAML2 endpoint/credential/settings fields, OIDC pkce_required preservation, round-trip fidelity, and absence of invented OIDC fields on SAML2 output Co-Authored-By: Claude Code --- ...ntityProviderProtocolDeserializerTest.java | 290 ++++++++++++++++++ src/swagger/api.yaml | 178 +++++------ 2 files changed, 380 insertions(+), 88 deletions(-) create mode 100644 impl/src/test/java/com/okta/sdk/impl/deserializer/IdentityProviderProtocolDeserializerTest.java diff --git a/impl/src/test/java/com/okta/sdk/impl/deserializer/IdentityProviderProtocolDeserializerTest.java b/impl/src/test/java/com/okta/sdk/impl/deserializer/IdentityProviderProtocolDeserializerTest.java new file mode 100644 index 00000000000..22ee29e376f --- /dev/null +++ b/impl/src/test/java/com/okta/sdk/impl/deserializer/IdentityProviderProtocolDeserializerTest.java @@ -0,0 +1,290 @@ +/* + * Copyright 2024-Present Okta, Inc. + * + * 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 com.okta.sdk.impl.deserializer; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.DeserializationFeature; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import com.okta.sdk.resource.model.IdentityProviderProtocol; +import com.okta.sdk.resource.model.ProtocolEndpointBinding; +import com.okta.sdk.resource.model.ProtocolMtls; +import com.okta.sdk.resource.model.ProtocolOAuth; +import com.okta.sdk.resource.model.ProtocolOidc; +import com.okta.sdk.resource.model.ProtocolSaml; + +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +import static org.testng.Assert.*; + +/** + * Regression tests for OKTA-1175444: SAML/OIDC IdP protocol fields silently + * dropped during deserialization due to missing discriminator on + * {@code IdentityProvider.protocol}. + * + * Each test deserializes a wire-format JSON snapshot (matching what the Okta API + * actually returns) and asserts that previously-dropped fields are now populated + * in the correctly-typed subclass. + */ +public class IdentityProviderProtocolDeserializerTest { + + private ObjectMapper objectMapper; + + @BeforeMethod + public void setUp() { + // Mirror the ObjectMapper configuration used by ApiClient so tests + // exercise the same deserialization path as production code. + objectMapper = new ObjectMapper(); + objectMapper.setSerializationInclusion(JsonInclude.Include.NON_NULL); + objectMapper.setSerializationInclusion(JsonInclude.Include.NON_EMPTY); + objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false); + objectMapper.configure(DeserializationFeature.FAIL_ON_INVALID_SUBTYPE, false); + objectMapper.configure(DeserializationFeature.READ_UNKNOWN_ENUM_VALUES_AS_NULL, true); + objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + objectMapper.enable(SerializationFeature.WRITE_ENUMS_USING_TO_STRING); + objectMapper.enable(DeserializationFeature.READ_ENUMS_USING_TO_STRING); + objectMapper.enable(DeserializationFeature.ACCEPT_SINGLE_VALUE_AS_ARRAY); + objectMapper.registerModule(new JavaTimeModule()); + } + + // ------------------------------------------------------------------------- + // SAML2 protocol + // ------------------------------------------------------------------------- + + @Test + public void saml2Protocol_deserializesToProtocolSamlSubtype() throws JsonProcessingException { + String json = saml2ProtocolJson(); + + IdentityProviderProtocol protocol = objectMapper.readValue(json, IdentityProviderProtocol.class); + + assertNotNull(protocol); + assertTrue(protocol instanceof ProtocolSaml, + "Expected ProtocolSaml but got " + protocol.getClass().getSimpleName()); + } + + @Test + public void saml2Protocol_ssoEndpointFieldsPopulated() throws JsonProcessingException { + ProtocolSaml saml = (ProtocolSaml) objectMapper.readValue(saml2ProtocolJson(), IdentityProviderProtocol.class); + + assertNotNull(saml.getEndpoints(), "endpoints must not be null"); + assertNotNull(saml.getEndpoints().getSso(), "endpoints.sso must not be null"); + assertEquals(saml.getEndpoints().getSso().getUrl(), "https://idp.example.com/sso/saml"); + assertEquals(saml.getEndpoints().getSso().getBinding(), ProtocolEndpointBinding.HTTP_REDIRECT); + assertEquals(saml.getEndpoints().getSso().getDestination(), "https://idp.example.com/sso/saml"); + } + + @Test + public void saml2Protocol_acsEndpointFieldsPopulated() throws JsonProcessingException { + ProtocolSaml saml = (ProtocolSaml) objectMapper.readValue(saml2ProtocolJson(), IdentityProviderProtocol.class); + + assertNotNull(saml.getEndpoints().getAcs(), "endpoints.acs must not be null"); + assertEquals(saml.getEndpoints().getAcs().getBinding(), ProtocolEndpointBinding.HTTP_POST); + } + + @Test + public void saml2Protocol_trustCredentialsPopulated() throws JsonProcessingException { + ProtocolSaml saml = (ProtocolSaml) objectMapper.readValue(saml2ProtocolJson(), IdentityProviderProtocol.class); + + assertNotNull(saml.getCredentials(), "credentials must not be null"); + assertNotNull(saml.getCredentials().getTrust(), "credentials.trust must not be null"); + assertEquals(saml.getCredentials().getTrust().getIssuer(), "https://idp.example.com"); + assertEquals(saml.getCredentials().getTrust().getAudience(), "https://www.okta.com/saml2/service-provider/xyz"); + assertEquals(saml.getCredentials().getTrust().getKid(), "your-key-id"); + } + + @Test + public void saml2Protocol_settingsNameFormatPopulated() throws JsonProcessingException { + ProtocolSaml saml = (ProtocolSaml) objectMapper.readValue(saml2ProtocolJson(), IdentityProviderProtocol.class); + + assertNotNull(saml.getSettings(), "settings must not be null"); + assertNotNull(saml.getSettings().getNameFormat(), "settings.nameFormat must not be null"); + } + + @Test + public void saml2Protocol_typeFieldCorrect() throws JsonProcessingException { + ProtocolSaml saml = (ProtocolSaml) objectMapper.readValue(saml2ProtocolJson(), IdentityProviderProtocol.class); + + assertEquals(saml.getType().getValue(), "SAML2"); + } + + @Test + public void saml2Protocol_roundTripPreservesKeyFields() throws JsonProcessingException { + ProtocolSaml original = (ProtocolSaml) objectMapper.readValue(saml2ProtocolJson(), IdentityProviderProtocol.class); + String serialized = objectMapper.writeValueAsString(original); + ProtocolSaml roundTripped = (ProtocolSaml) objectMapper.readValue(serialized, IdentityProviderProtocol.class); + + assertEquals(roundTripped.getEndpoints().getSso().getUrl(), + original.getEndpoints().getSso().getUrl(), + "sso.url must survive a serialize/deserialize round-trip"); + assertEquals(roundTripped.getCredentials().getTrust().getIssuer(), + original.getCredentials().getTrust().getIssuer(), + "credentials.trust.issuer must survive a serialize/deserialize round-trip"); + } + + @Test + public void saml2Protocol_noOidcEndpointsInvented() throws JsonProcessingException { + // Regression: the flat IdentityProviderProtocol class used to set OIDC-shaped + // endpoints (authorization, token, jwks, par) on a SAML2 IdP. After the fix, + // the ProtocolSaml class has no such fields; this test confirms they are absent + // from the serialized output. + ProtocolSaml saml = (ProtocolSaml) objectMapper.readValue(saml2ProtocolJson(), IdentityProviderProtocol.class); + String serialized = objectMapper.writeValueAsString(saml); + + assertFalse(serialized.contains("\"authorization\""), + "SAML2 serialized output must not contain OIDC authorization endpoint"); + assertFalse(serialized.contains("\"token\""), + "SAML2 serialized output must not contain OIDC token endpoint"); + assertFalse(serialized.contains("\"jwks\""), + "SAML2 serialized output must not contain OIDC jwks endpoint"); + } + + // ------------------------------------------------------------------------- + // OIDC protocol + // ------------------------------------------------------------------------- + + @Test + public void oidcProtocol_deserializesToProtocolOidcSubtype() throws JsonProcessingException { + IdentityProviderProtocol protocol = objectMapper.readValue(oidcProtocolJson(), IdentityProviderProtocol.class); + + assertNotNull(protocol); + assertTrue(protocol instanceof ProtocolOidc, + "Expected ProtocolOidc but got " + protocol.getClass().getSimpleName()); + } + + @Test + public void oidcProtocol_pkceRequiredPreserved() throws JsonProcessingException { + // Regression: pkce_required=true was silently dropped by the flat class, + // which would have disabled PKCE server-side on replaceIdentityProvider. + ProtocolOidc oidc = (ProtocolOidc) objectMapper.readValue(oidcProtocolJson(), IdentityProviderProtocol.class); + + assertNotNull(oidc.getCredentials(), "credentials must not be null"); + assertNotNull(oidc.getCredentials().getClient(), "credentials.client must not be null"); + assertTrue(oidc.getCredentials().getClient().getPkceRequired(), + "pkce_required=true must be preserved after deserialization"); + } + + @Test + public void oidcProtocol_userInfoEndpointPopulated() throws JsonProcessingException { + ProtocolOidc oidc = (ProtocolOidc) objectMapper.readValue(oidcProtocolJson(), IdentityProviderProtocol.class); + + assertNotNull(oidc.getEndpoints(), "endpoints must not be null"); + assertNotNull(oidc.getEndpoints().getUserInfo(), "endpoints.userInfo must not be null"); + assertEquals(oidc.getEndpoints().getUserInfo().getUrl(), + "https://idp.example.com/oauth2/v1/userinfo"); + assertEquals(oidc.getEndpoints().getUserInfo().getBinding(), ProtocolEndpointBinding.HTTP_REDIRECT); + } + + @Test + public void oidcProtocol_roundTripPreservesPkce() throws JsonProcessingException { + ProtocolOidc original = (ProtocolOidc) objectMapper.readValue(oidcProtocolJson(), IdentityProviderProtocol.class); + String serialized = objectMapper.writeValueAsString(original); + ProtocolOidc roundTripped = (ProtocolOidc) objectMapper.readValue(serialized, IdentityProviderProtocol.class); + + assertTrue(roundTripped.getCredentials().getClient().getPkceRequired(), + "pkce_required must survive a serialize/deserialize round-trip"); + } + + // ------------------------------------------------------------------------- + // OAUTH2 protocol + // ------------------------------------------------------------------------- + + @Test + public void oauth2Protocol_deserializesToProtocolOAuthSubtype() throws JsonProcessingException { + String json = "{\"type\":\"OAUTH2\",\"credentials\":{\"client\":{\"client_id\":\"abc\",\"client_secret\":\"secret\"}}}"; + + IdentityProviderProtocol protocol = objectMapper.readValue(json, IdentityProviderProtocol.class); + + assertNotNull(protocol); + assertTrue(protocol instanceof ProtocolOAuth, + "Expected ProtocolOAuth but got " + protocol.getClass().getSimpleName()); + } + + // ------------------------------------------------------------------------- + // MTLS protocol + // ------------------------------------------------------------------------- + + @Test + public void mtlsProtocol_deserializesToProtocolMtlsSubtype() throws JsonProcessingException { + String json = "{\"type\":\"MTLS\"}"; + + IdentityProviderProtocol protocol = objectMapper.readValue(json, IdentityProviderProtocol.class); + + assertNotNull(protocol); + assertTrue(protocol instanceof ProtocolMtls, + "Expected ProtocolMtls but got " + protocol.getClass().getSimpleName()); + } + + // ------------------------------------------------------------------------- + // JSON fixtures (wire-format snapshots matching real Okta API responses) + // ------------------------------------------------------------------------- + + private static String saml2ProtocolJson() { + return "{" + + "\"type\": \"SAML2\"," + + "\"algorithms\": {" + + " \"request\": {\"signature\": {\"algorithm\": \"SHA-256\", \"scope\": \"REQUEST\"}}," + + " \"response\": {\"signature\": {\"algorithm\": \"SHA-256\", \"scope\": \"ANY\"}}" + + "}," + + "\"credentials\": {" + + " \"signing\": {\"kid\": \"signing-key-id\"}," + + " \"trust\": {" + + " \"issuer\": \"https://idp.example.com\"," + + " \"audience\": \"https://www.okta.com/saml2/service-provider/xyz\"," + + " \"kid\": \"your-key-id\"" + + " }" + + "}," + + "\"endpoints\": {" + + " \"sso\": {" + + " \"url\": \"https://idp.example.com/sso/saml\"," + + " \"binding\": \"HTTP-REDIRECT\"," + + " \"destination\": \"https://idp.example.com/sso/saml\"" + + " }," + + " \"acs\": {" + + " \"binding\": \"HTTP-POST\"," + + " \"type\": \"INSTANCE\"" + + " }" + + "}," + + "\"settings\": {" + + " \"nameFormat\": \"urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified\"," + + " \"honorPersistentNameId\": true" + + "}" + + "}"; + } + + private static String oidcProtocolJson() { + return "{" + + "\"type\": \"OIDC\"," + + "\"credentials\": {" + + " \"client\": {" + + " \"client_id\": \"my-client-id\"," + + " \"pkce_required\": true" + + " }" + + "}," + + "\"endpoints\": {" + + " \"userInfo\": {" + + " \"url\": \"https://idp.example.com/oauth2/v1/userinfo\"," + + " \"binding\": \"HTTP-REDIRECT\"" + + " }" + + "}," + + "\"settings\": {" + + " \"nameFormat\": \"urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified\"" + + "}" + + "}"; + } +} diff --git a/src/swagger/api.yaml b/src/swagger/api.yaml index 20ec4314356..27e1d5068f9 100644 --- a/src/swagger/api.yaml +++ b/src/swagger/api.yaml @@ -67174,13 +67174,7 @@ components: properties: $ref: '#/components/schemas/IdentityProviderProperties' protocol: - description: IdP-specific protocol settings for endpoints, bindings, and algorithms used to connect with the IdP and validate messages - oneOf: - - $ref: '#/components/schemas/ProtocolSaml' - - $ref: '#/components/schemas/ProtocolOAuth' - - $ref: '#/components/schemas/ProtocolOidc' - - $ref: '#/components/schemas/ProtocolMtls' - - $ref: '#/components/schemas/ProtocolIdVerification' + $ref: '#/components/schemas/IdentityProviderProtocol' status: $ref: '#/components/schemas/LifecycleStatus' type: @@ -67342,6 +67336,29 @@ components: type: string provider: $ref: '#/components/schemas/IdentityProviderPolicyProvider' + IdentityProviderProtocol: + description: IdP-specific protocol settings for endpoints, bindings, and algorithms used to connect with the IdP and validate messages + type: object + required: + - type + properties: + type: + type: string + description: The authentication protocol type + enum: + - SAML2 + - OAUTH2 + - OIDC + - MTLS + - ID_PROOFING + discriminator: + propertyName: type + mapping: + SAML2: '#/components/schemas/ProtocolSaml' + OAUTH2: '#/components/schemas/ProtocolOAuth' + OIDC: '#/components/schemas/ProtocolOidc' + MTLS: '#/components/schemas/ProtocolMtls' + ID_PROOFING: '#/components/schemas/ProtocolIdVerification' IdentityProviderProperties: nullable: true description: The properties in the IdP `properties` object vary depending on the IdP type @@ -74817,99 +74834,84 @@ components: ProtocolIdVerification: title: ID Verification description: Protocol settings for the IDV vendor - type: object - properties: - credentials: - $ref: '#/components/schemas/IDVCredentials' - endpoints: - $ref: '#/components/schemas/IDVEndpoints' - scopes: - $ref: '#/components/schemas/OAuthScopes' - type: - type: string - description: ID verification protocol - enum: - - ID_PROOFING + allOf: + - $ref: '#/components/schemas/IdentityProviderProtocol' + - type: object + properties: + credentials: + $ref: '#/components/schemas/IDVCredentials' + endpoints: + $ref: '#/components/schemas/IDVEndpoints' + scopes: + $ref: '#/components/schemas/OAuthScopes' ProtocolMtls: title: Mutual TLS Protocol description: Protocol settings for the [MTLS Protocol](https://tools.ietf.org/html/rfc5246#section-7.4.4) - type: object - properties: - credentials: - $ref: '#/components/schemas/MtlsCredentials' - endpoints: - $ref: '#/components/schemas/MtlsEndpoints' - type: - type: string - description: Mutual TLS - enum: - - MTLS + allOf: + - $ref: '#/components/schemas/IdentityProviderProtocol' + - type: object + properties: + credentials: + $ref: '#/components/schemas/MtlsCredentials' + endpoints: + $ref: '#/components/schemas/MtlsEndpoints' ProtocolOAuth: title: OAuth 2.0 Protocol description: Protocol settings for authentication using the [OAuth 2.0 Authorization Code flow](https://tools.ietf.org/html/rfc6749#section-4.1) - type: object - properties: - credentials: - $ref: '#/components/schemas/OAuthCredentials' - endpoints: - $ref: '#/components/schemas/OAuthEndpoints' - scopes: - $ref: '#/components/schemas/OAuthScopes' - type: - type: string - description: OAuth 2.0 Authorization Code flow - enum: - - OAUTH2 + allOf: + - $ref: '#/components/schemas/IdentityProviderProtocol' + - type: object + properties: + credentials: + $ref: '#/components/schemas/OAuthCredentials' + endpoints: + $ref: '#/components/schemas/OAuthEndpoints' + scopes: + $ref: '#/components/schemas/OAuthScopes' ProtocolOidc: title: OpenID Connect Protocol description: Protocol settings for authentication using the [OpenID Connect Protocol](http://openid.net/specs/openid-connect-core-1_0.html#CodeFlowAuth) - type: object - properties: - algorithms: - $ref: '#/components/schemas/OidcAlgorithms' - credentials: - $ref: '#/components/schemas/OAuthCredentials' - endpoints: - $ref: '#/components/schemas/OAuthEndpoints' - oktaIdpOrgUrl: - type: string - description: URL of the IdP org - example: https://idp.example.com - scopes: - type: array - description: |- - OpenID Connect and IdP-defined permission bundles to request delegated access from the user - > **Note:** The [IdP type](https://developer.okta.com/docs/api/openapi/okta-management/management/tag/IdentityProvider/#tag/IdentityProvider/operation/createIdentityProvider!path=type&t=request) table lists the scopes that are supported for each IdP. - items: - type: string - example: openid - settings: - $ref: '#/components/schemas/OidcSettings' - type: - type: string - description: OpenID Connect Authorization Code flow - enum: - - OIDC + allOf: + - $ref: '#/components/schemas/IdentityProviderProtocol' + - type: object + properties: + algorithms: + $ref: '#/components/schemas/OidcAlgorithms' + credentials: + $ref: '#/components/schemas/OAuthCredentials' + endpoints: + $ref: '#/components/schemas/OAuthEndpoints' + oktaIdpOrgUrl: + type: string + description: URL of the IdP org + example: https://idp.example.com + scopes: + type: array + description: |- + OpenID Connect and IdP-defined permission bundles to request delegated access from the user + > **Note:** The [IdP type](https://developer.okta.com/docs/api/openapi/okta-management/management/tag/IdentityProvider/#tag/IdentityProvider/operation/createIdentityProvider!path=type&t=request) table lists the scopes that are supported for each IdP. + items: + type: string + example: openid + settings: + $ref: '#/components/schemas/OidcSettings' ProtocolSaml: title: SAML 2.0 Protocol description: Protocol settings for the [SAML 2.0 Authentication Request Protocol](http://docs.oasis-open.org/security/saml/v2.0/saml-core-2.0-os.pdf) - type: object - properties: - algorithms: - $ref: '#/components/schemas/SamlAlgorithms' - credentials: - $ref: '#/components/schemas/SamlCredentials' - endpoints: - $ref: '#/components/schemas/SamlEndpoints' - relayState: - $ref: '#/components/schemas/SamlRelayState' - settings: - $ref: '#/components/schemas/SamlSettings' - type: - type: string - description: SAML 2.0 protocol - enum: - - SAML2 + allOf: + - $ref: '#/components/schemas/IdentityProviderProtocol' + - type: object + properties: + algorithms: + $ref: '#/components/schemas/SamlAlgorithms' + credentials: + $ref: '#/components/schemas/SamlCredentials' + endpoints: + $ref: '#/components/schemas/SamlEndpoints' + relayState: + $ref: '#/components/schemas/SamlRelayState' + settings: + $ref: '#/components/schemas/SamlSettings' ProtocolType: description: The authentication protocol type used for the connection type: string