Skip to content

Commit 30a3e0f

Browse files
fix(spec): add discriminator to IdentityProvider.protocol to fix SAML… (#1695)
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
1 parent bcf8673 commit 30a3e0f

2 files changed

Lines changed: 380 additions & 88 deletions

File tree

Lines changed: 290 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,290 @@
1+
/*
2+
* Copyright 2024-Present Okta, Inc.
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* http://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package com.okta.sdk.impl.deserializer;
17+
18+
import com.fasterxml.jackson.annotation.JsonInclude;
19+
import com.fasterxml.jackson.core.JsonProcessingException;
20+
import com.fasterxml.jackson.databind.DeserializationFeature;
21+
import com.fasterxml.jackson.databind.ObjectMapper;
22+
import com.fasterxml.jackson.databind.SerializationFeature;
23+
import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule;
24+
import com.okta.sdk.resource.model.IdentityProviderProtocol;
25+
import com.okta.sdk.resource.model.ProtocolEndpointBinding;
26+
import com.okta.sdk.resource.model.ProtocolMtls;
27+
import com.okta.sdk.resource.model.ProtocolOAuth;
28+
import com.okta.sdk.resource.model.ProtocolOidc;
29+
import com.okta.sdk.resource.model.ProtocolSaml;
30+
31+
import org.testng.annotations.BeforeMethod;
32+
import org.testng.annotations.Test;
33+
34+
import static org.testng.Assert.*;
35+
36+
/**
37+
* Regression tests for OKTA-1175444: SAML/OIDC IdP protocol fields silently
38+
* dropped during deserialization due to missing discriminator on
39+
* {@code IdentityProvider.protocol}.
40+
*
41+
* Each test deserializes a wire-format JSON snapshot (matching what the Okta API
42+
* actually returns) and asserts that previously-dropped fields are now populated
43+
* in the correctly-typed subclass.
44+
*/
45+
public class IdentityProviderProtocolDeserializerTest {
46+
47+
private ObjectMapper objectMapper;
48+
49+
@BeforeMethod
50+
public void setUp() {
51+
// Mirror the ObjectMapper configuration used by ApiClient so tests
52+
// exercise the same deserialization path as production code.
53+
objectMapper = new ObjectMapper();
54+
objectMapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);
55+
objectMapper.setSerializationInclusion(JsonInclude.Include.NON_EMPTY);
56+
objectMapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
57+
objectMapper.configure(DeserializationFeature.FAIL_ON_INVALID_SUBTYPE, false);
58+
objectMapper.configure(DeserializationFeature.READ_UNKNOWN_ENUM_VALUES_AS_NULL, true);
59+
objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS);
60+
objectMapper.enable(SerializationFeature.WRITE_ENUMS_USING_TO_STRING);
61+
objectMapper.enable(DeserializationFeature.READ_ENUMS_USING_TO_STRING);
62+
objectMapper.enable(DeserializationFeature.ACCEPT_SINGLE_VALUE_AS_ARRAY);
63+
objectMapper.registerModule(new JavaTimeModule());
64+
}
65+
66+
// -------------------------------------------------------------------------
67+
// SAML2 protocol
68+
// -------------------------------------------------------------------------
69+
70+
@Test
71+
public void saml2Protocol_deserializesToProtocolSamlSubtype() throws JsonProcessingException {
72+
String json = saml2ProtocolJson();
73+
74+
IdentityProviderProtocol protocol = objectMapper.readValue(json, IdentityProviderProtocol.class);
75+
76+
assertNotNull(protocol);
77+
assertTrue(protocol instanceof ProtocolSaml,
78+
"Expected ProtocolSaml but got " + protocol.getClass().getSimpleName());
79+
}
80+
81+
@Test
82+
public void saml2Protocol_ssoEndpointFieldsPopulated() throws JsonProcessingException {
83+
ProtocolSaml saml = (ProtocolSaml) objectMapper.readValue(saml2ProtocolJson(), IdentityProviderProtocol.class);
84+
85+
assertNotNull(saml.getEndpoints(), "endpoints must not be null");
86+
assertNotNull(saml.getEndpoints().getSso(), "endpoints.sso must not be null");
87+
assertEquals(saml.getEndpoints().getSso().getUrl(), "https://idp.example.com/sso/saml");
88+
assertEquals(saml.getEndpoints().getSso().getBinding(), ProtocolEndpointBinding.HTTP_REDIRECT);
89+
assertEquals(saml.getEndpoints().getSso().getDestination(), "https://idp.example.com/sso/saml");
90+
}
91+
92+
@Test
93+
public void saml2Protocol_acsEndpointFieldsPopulated() throws JsonProcessingException {
94+
ProtocolSaml saml = (ProtocolSaml) objectMapper.readValue(saml2ProtocolJson(), IdentityProviderProtocol.class);
95+
96+
assertNotNull(saml.getEndpoints().getAcs(), "endpoints.acs must not be null");
97+
assertEquals(saml.getEndpoints().getAcs().getBinding(), ProtocolEndpointBinding.HTTP_POST);
98+
}
99+
100+
@Test
101+
public void saml2Protocol_trustCredentialsPopulated() throws JsonProcessingException {
102+
ProtocolSaml saml = (ProtocolSaml) objectMapper.readValue(saml2ProtocolJson(), IdentityProviderProtocol.class);
103+
104+
assertNotNull(saml.getCredentials(), "credentials must not be null");
105+
assertNotNull(saml.getCredentials().getTrust(), "credentials.trust must not be null");
106+
assertEquals(saml.getCredentials().getTrust().getIssuer(), "https://idp.example.com");
107+
assertEquals(saml.getCredentials().getTrust().getAudience(), "https://www.okta.com/saml2/service-provider/xyz");
108+
assertEquals(saml.getCredentials().getTrust().getKid(), "your-key-id");
109+
}
110+
111+
@Test
112+
public void saml2Protocol_settingsNameFormatPopulated() throws JsonProcessingException {
113+
ProtocolSaml saml = (ProtocolSaml) objectMapper.readValue(saml2ProtocolJson(), IdentityProviderProtocol.class);
114+
115+
assertNotNull(saml.getSettings(), "settings must not be null");
116+
assertNotNull(saml.getSettings().getNameFormat(), "settings.nameFormat must not be null");
117+
}
118+
119+
@Test
120+
public void saml2Protocol_typeFieldCorrect() throws JsonProcessingException {
121+
ProtocolSaml saml = (ProtocolSaml) objectMapper.readValue(saml2ProtocolJson(), IdentityProviderProtocol.class);
122+
123+
assertEquals(saml.getType().getValue(), "SAML2");
124+
}
125+
126+
@Test
127+
public void saml2Protocol_roundTripPreservesKeyFields() throws JsonProcessingException {
128+
ProtocolSaml original = (ProtocolSaml) objectMapper.readValue(saml2ProtocolJson(), IdentityProviderProtocol.class);
129+
String serialized = objectMapper.writeValueAsString(original);
130+
ProtocolSaml roundTripped = (ProtocolSaml) objectMapper.readValue(serialized, IdentityProviderProtocol.class);
131+
132+
assertEquals(roundTripped.getEndpoints().getSso().getUrl(),
133+
original.getEndpoints().getSso().getUrl(),
134+
"sso.url must survive a serialize/deserialize round-trip");
135+
assertEquals(roundTripped.getCredentials().getTrust().getIssuer(),
136+
original.getCredentials().getTrust().getIssuer(),
137+
"credentials.trust.issuer must survive a serialize/deserialize round-trip");
138+
}
139+
140+
@Test
141+
public void saml2Protocol_noOidcEndpointsInvented() throws JsonProcessingException {
142+
// Regression: the flat IdentityProviderProtocol class used to set OIDC-shaped
143+
// endpoints (authorization, token, jwks, par) on a SAML2 IdP. After the fix,
144+
// the ProtocolSaml class has no such fields; this test confirms they are absent
145+
// from the serialized output.
146+
ProtocolSaml saml = (ProtocolSaml) objectMapper.readValue(saml2ProtocolJson(), IdentityProviderProtocol.class);
147+
String serialized = objectMapper.writeValueAsString(saml);
148+
149+
assertFalse(serialized.contains("\"authorization\""),
150+
"SAML2 serialized output must not contain OIDC authorization endpoint");
151+
assertFalse(serialized.contains("\"token\""),
152+
"SAML2 serialized output must not contain OIDC token endpoint");
153+
assertFalse(serialized.contains("\"jwks\""),
154+
"SAML2 serialized output must not contain OIDC jwks endpoint");
155+
}
156+
157+
// -------------------------------------------------------------------------
158+
// OIDC protocol
159+
// -------------------------------------------------------------------------
160+
161+
@Test
162+
public void oidcProtocol_deserializesToProtocolOidcSubtype() throws JsonProcessingException {
163+
IdentityProviderProtocol protocol = objectMapper.readValue(oidcProtocolJson(), IdentityProviderProtocol.class);
164+
165+
assertNotNull(protocol);
166+
assertTrue(protocol instanceof ProtocolOidc,
167+
"Expected ProtocolOidc but got " + protocol.getClass().getSimpleName());
168+
}
169+
170+
@Test
171+
public void oidcProtocol_pkceRequiredPreserved() throws JsonProcessingException {
172+
// Regression: pkce_required=true was silently dropped by the flat class,
173+
// which would have disabled PKCE server-side on replaceIdentityProvider.
174+
ProtocolOidc oidc = (ProtocolOidc) objectMapper.readValue(oidcProtocolJson(), IdentityProviderProtocol.class);
175+
176+
assertNotNull(oidc.getCredentials(), "credentials must not be null");
177+
assertNotNull(oidc.getCredentials().getClient(), "credentials.client must not be null");
178+
assertTrue(oidc.getCredentials().getClient().getPkceRequired(),
179+
"pkce_required=true must be preserved after deserialization");
180+
}
181+
182+
@Test
183+
public void oidcProtocol_userInfoEndpointPopulated() throws JsonProcessingException {
184+
ProtocolOidc oidc = (ProtocolOidc) objectMapper.readValue(oidcProtocolJson(), IdentityProviderProtocol.class);
185+
186+
assertNotNull(oidc.getEndpoints(), "endpoints must not be null");
187+
assertNotNull(oidc.getEndpoints().getUserInfo(), "endpoints.userInfo must not be null");
188+
assertEquals(oidc.getEndpoints().getUserInfo().getUrl(),
189+
"https://idp.example.com/oauth2/v1/userinfo");
190+
assertEquals(oidc.getEndpoints().getUserInfo().getBinding(), ProtocolEndpointBinding.HTTP_REDIRECT);
191+
}
192+
193+
@Test
194+
public void oidcProtocol_roundTripPreservesPkce() throws JsonProcessingException {
195+
ProtocolOidc original = (ProtocolOidc) objectMapper.readValue(oidcProtocolJson(), IdentityProviderProtocol.class);
196+
String serialized = objectMapper.writeValueAsString(original);
197+
ProtocolOidc roundTripped = (ProtocolOidc) objectMapper.readValue(serialized, IdentityProviderProtocol.class);
198+
199+
assertTrue(roundTripped.getCredentials().getClient().getPkceRequired(),
200+
"pkce_required must survive a serialize/deserialize round-trip");
201+
}
202+
203+
// -------------------------------------------------------------------------
204+
// OAUTH2 protocol
205+
// -------------------------------------------------------------------------
206+
207+
@Test
208+
public void oauth2Protocol_deserializesToProtocolOAuthSubtype() throws JsonProcessingException {
209+
String json = "{\"type\":\"OAUTH2\",\"credentials\":{\"client\":{\"client_id\":\"abc\",\"client_secret\":\"secret\"}}}";
210+
211+
IdentityProviderProtocol protocol = objectMapper.readValue(json, IdentityProviderProtocol.class);
212+
213+
assertNotNull(protocol);
214+
assertTrue(protocol instanceof ProtocolOAuth,
215+
"Expected ProtocolOAuth but got " + protocol.getClass().getSimpleName());
216+
}
217+
218+
// -------------------------------------------------------------------------
219+
// MTLS protocol
220+
// -------------------------------------------------------------------------
221+
222+
@Test
223+
public void mtlsProtocol_deserializesToProtocolMtlsSubtype() throws JsonProcessingException {
224+
String json = "{\"type\":\"MTLS\"}";
225+
226+
IdentityProviderProtocol protocol = objectMapper.readValue(json, IdentityProviderProtocol.class);
227+
228+
assertNotNull(protocol);
229+
assertTrue(protocol instanceof ProtocolMtls,
230+
"Expected ProtocolMtls but got " + protocol.getClass().getSimpleName());
231+
}
232+
233+
// -------------------------------------------------------------------------
234+
// JSON fixtures (wire-format snapshots matching real Okta API responses)
235+
// -------------------------------------------------------------------------
236+
237+
private static String saml2ProtocolJson() {
238+
return "{"
239+
+ "\"type\": \"SAML2\","
240+
+ "\"algorithms\": {"
241+
+ " \"request\": {\"signature\": {\"algorithm\": \"SHA-256\", \"scope\": \"REQUEST\"}},"
242+
+ " \"response\": {\"signature\": {\"algorithm\": \"SHA-256\", \"scope\": \"ANY\"}}"
243+
+ "},"
244+
+ "\"credentials\": {"
245+
+ " \"signing\": {\"kid\": \"signing-key-id\"},"
246+
+ " \"trust\": {"
247+
+ " \"issuer\": \"https://idp.example.com\","
248+
+ " \"audience\": \"https://www.okta.com/saml2/service-provider/xyz\","
249+
+ " \"kid\": \"your-key-id\""
250+
+ " }"
251+
+ "},"
252+
+ "\"endpoints\": {"
253+
+ " \"sso\": {"
254+
+ " \"url\": \"https://idp.example.com/sso/saml\","
255+
+ " \"binding\": \"HTTP-REDIRECT\","
256+
+ " \"destination\": \"https://idp.example.com/sso/saml\""
257+
+ " },"
258+
+ " \"acs\": {"
259+
+ " \"binding\": \"HTTP-POST\","
260+
+ " \"type\": \"INSTANCE\""
261+
+ " }"
262+
+ "},"
263+
+ "\"settings\": {"
264+
+ " \"nameFormat\": \"urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified\","
265+
+ " \"honorPersistentNameId\": true"
266+
+ "}"
267+
+ "}";
268+
}
269+
270+
private static String oidcProtocolJson() {
271+
return "{"
272+
+ "\"type\": \"OIDC\","
273+
+ "\"credentials\": {"
274+
+ " \"client\": {"
275+
+ " \"client_id\": \"my-client-id\","
276+
+ " \"pkce_required\": true"
277+
+ " }"
278+
+ "},"
279+
+ "\"endpoints\": {"
280+
+ " \"userInfo\": {"
281+
+ " \"url\": \"https://idp.example.com/oauth2/v1/userinfo\","
282+
+ " \"binding\": \"HTTP-REDIRECT\""
283+
+ " }"
284+
+ "},"
285+
+ "\"settings\": {"
286+
+ " \"nameFormat\": \"urn:oasis:names:tc:SAML:1.1:nameid-format:unspecified\""
287+
+ "}"
288+
+ "}";
289+
}
290+
}

0 commit comments

Comments
 (0)