Skip to content

Commit b96509a

Browse files
authored
new method for linking existing idps (#159)
1 parent 05583b8 commit b96509a

File tree

7 files changed

+186
-32
lines changed

7 files changed

+186
-32
lines changed

README.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -183,9 +183,11 @@ There are some non-standard flows where the required action does not do this det
183183

184184
#### IdP Discovery
185185

186-
Organizations may optionally be given permission to manage their own IdP. The custom resources that allow this write a configuration in the IdP entities that is compatible with a 3rd party extension that allows for IdP discovery based on email domain configured for the Organization. It works by writing the `home.idp.discovery.domains` value into the `config` map for the IdP. Information on further configuration is available at [sventorben/keycloak-home-idp-discovery](https://github.com/sventorben/keycloak-home-idp-discovery).
186+
Organizations may optionally be given permission to manage their own IdP. The custom resources that allow this write a configuration in the IdP entities that is compatible with a 3rd party extension that allows for IdP discovery based on email domain configured for the Organization. It works by writing the `home.idp.discovery.orgs` value into the `config` map for the IdP. Information on further configuration is available at [sventorben/keycloak-home-idp-discovery](https://github.com/sventorben/keycloak-home-idp-discovery). However, please note that the internal discovery portion has been *forked* from his version, and does not look up IdPs in the same way.
187187

188-
tbd screenshot of installing in flow
188+
![mapper](./docs/assets/home-idp-discovery-config.png)
189+
190+
These are the configuration options for the "Home IdP Discovery" Authenticator. It will need to be placed in your flow as a replacement for a "Username form", or after another Authenticator/Form that sets the `ATTEMPTED_USERNAME` note.
189191

190192
## License
191193

@@ -196,6 +198,6 @@ We’ve changed the license of our core extensions from the AGPL v3 to the [Elas
196198

197199
-----
198200

199-
Portions of the [Home IdP Discovery](https://github.com/p2-inc/keycloak-orgs/tree/main/src/main/java/io/phasetwo/service/auth/idp) code are Copyright (c) 2021-2023 Sven-Torben Janus, and are licensed under the [MIT License](https://github.com/p2-inc/keycloak-orgs/blob/main/src/main/java/io/phasetwo/service/auth/idp/LICENSE.md).
201+
Portions of the [Home IdP Discovery](https://github.com/p2-inc/keycloak-orgs/tree/main/src/main/java/io/phasetwo/service/auth/idp) code are Copyright (c) 2021-2024 Sven-Torben Janus, and are licensed under the [MIT License](https://github.com/p2-inc/keycloak-orgs/blob/main/src/main/java/io/phasetwo/service/auth/idp/LICENSE.md).
200202

201-
All other ocumentation, source code and other files in this repository are Copyright 2023 Phase Two, Inc.
203+
All other documentation, source code and other files in this repository are Copyright 2024 Phase Two, Inc.
38.1 KB
Loading

src/main/java/io/phasetwo/service/Orgs.java

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,7 @@ public class Orgs {
55
public static final String ORG_OWNER_CONFIG_KEY = "home.idp.discovery.org";
66
public static final String FIELD_ORG_ID = "org_id";
77
public static final String ORG_AUTH_FLOW_ALIAS = "post org broker login";
8+
public static final String ORG_DEFAULT_POST_BROKER_FLOW_KEY =
9+
"_providerConfig.orgs.defaults.postBrokerFlow";
10+
public static final String ORG_DEFAULT_SYNC_MODE_KEY = "_providerConfig.orgs.defaults.syncMode";
811
}
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
package io.phasetwo.service.representation;
2+
3+
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
4+
import com.fasterxml.jackson.annotation.JsonProperty;
5+
import lombok.Data;
6+
7+
@Data
8+
@JsonIgnoreProperties(ignoreUnknown = true)
9+
public class LinkIdp {
10+
11+
@JsonProperty("alias")
12+
private String alias;
13+
14+
@JsonProperty("post_broker_flow")
15+
private String postBrokerFlow;
16+
17+
@JsonProperty("sync_mode")
18+
private String syncMode;
19+
}

src/main/java/io/phasetwo/service/resource/IdentityProviderResource.java

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package io.phasetwo.service.resource;
22

3+
import static io.phasetwo.service.Orgs.*;
34
import static io.phasetwo.service.resource.Converters.*;
45
import static io.phasetwo.service.resource.OrganizationResourceType.*;
56

@@ -48,7 +49,8 @@ public Response delete() {
4849
public Response update(IdentityProviderRepresentation providerRep) {
4950
requireManage();
5051
// don't allow override of ownership and other conf vars
51-
IdentityProvidersResource.idpDefaults(organization, providerRep);
52+
providerRep.getConfig().put("hideOnLoginPage", "true");
53+
providerRep.getConfig().put(ORG_OWNER_CONFIG_KEY, organization.getId());
5254
// force alias
5355
providerRep.setAlias(alias);
5456

src/main/java/io/phasetwo/service/resource/IdentityProvidersResource.java

Lines changed: 94 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,16 @@
44
import static io.phasetwo.service.resource.Converters.*;
55
import static io.phasetwo.service.resource.OrganizationResourceType.*;
66

7+
import com.google.common.base.Strings;
78
import io.phasetwo.service.model.OrganizationModel;
9+
import io.phasetwo.service.representation.LinkIdp;
810
import jakarta.validation.constraints.*;
911
import jakarta.ws.rs.*;
1012
import jakarta.ws.rs.core.MediaType;
1113
import jakarta.ws.rs.core.Response;
1214
import java.io.IOException;
1315
import java.util.Map;
16+
import java.util.Optional;
1417
import java.util.stream.Stream;
1518
import lombok.extern.jbosslog.JBossLog;
1619
import org.keycloak.models.IdentityProviderModel;
@@ -55,30 +58,32 @@ public Stream<IdentityProviderRepresentation> getIdentityProviders() {
5558
StripSecretsUtils.strip(ModelToRepresentation.toRepresentation(realm, provider)));
5659
}
5760

58-
public static void idpDefaults(
59-
OrganizationModel organization, IdentityProviderRepresentation representation) {
61+
protected void idpDefaults(
62+
IdentityProviderRepresentation representation, Optional<LinkIdp> linkIdp) {
6063
// defaults? overrides?
61-
representation.getConfig().put("syncMode", "FORCE");
64+
String syncMode =
65+
linkIdp
66+
.map(LinkIdp::getSyncMode)
67+
.orElse(
68+
Optional.ofNullable(realm.getAttribute(ORG_DEFAULT_SYNC_MODE_KEY)).orElse("FORCE"));
69+
String postBrokerFlow =
70+
linkIdp
71+
.map(LinkIdp::getPostBrokerFlow)
72+
.orElse(
73+
Optional.ofNullable(realm.getAttribute(ORG_DEFAULT_POST_BROKER_FLOW_KEY))
74+
.orElse(ORG_AUTH_FLOW_ALIAS));
75+
log.debugf(
76+
"using syncMode %s, postBrokerFlow %s for idp %s",
77+
syncMode, postBrokerFlow, representation.getAlias());
78+
79+
representation.getConfig().put("syncMode", syncMode);
6280
representation.getConfig().put("hideOnLoginPage", "true");
6381
representation.getConfig().put(ORG_OWNER_CONFIG_KEY, organization.getId());
64-
representation.setPostBrokerLoginFlowAlias(ORG_AUTH_FLOW_ALIAS);
65-
// - firstBrokerLoginFlowAlias
82+
representation.setPostBrokerLoginFlowAlias(postBrokerFlow);
83+
// TODO firstBrokerLoginFlowAlias
6684
}
6785

68-
@POST
69-
@Consumes(MediaType.APPLICATION_JSON)
70-
public Response createIdentityProvider(IdentityProviderRepresentation representation) {
71-
if (!auth.hasManageOrgs() && !auth.hasOrgManageIdentityProviders(organization)) {
72-
throw new NotAuthorizedException(
73-
String.format(
74-
"Insufficient permission to create identity providers for %s", organization.getId()));
75-
}
76-
77-
// Override alias to prevent collisions
78-
// representation.setAlias(KeycloakModelUtils.generateId());
79-
80-
idpDefaults(organization, representation);
81-
86+
private void deactivateOtherIdps(IdentityProviderRepresentation representation) {
8287
// Organization can have only one active idp
8388
// Activating an idp deactivates all others
8489
if (representation.isEnabled()) {
@@ -91,17 +96,79 @@ public Response createIdentityProvider(IdentityProviderRepresentation representa
9196
realm.updateIdentityProvider(provider); // weird that this is necessary
9297
});
9398
}
99+
}
100+
101+
private Response createdResponse(IdentityProviderRepresentation representation) {
102+
return Response.created(
103+
session
104+
.getContext()
105+
.getUri()
106+
.getAbsolutePathBuilder()
107+
.path(representation.getAlias())
108+
.build())
109+
.build();
110+
}
111+
112+
@POST
113+
@Consumes(MediaType.APPLICATION_JSON)
114+
public Response createIdentityProvider(IdentityProviderRepresentation representation) {
115+
if (!auth.hasManageOrgs() && !auth.hasOrgManageIdentityProviders(organization)) {
116+
throw new NotAuthorizedException(
117+
String.format(
118+
"Insufficient permission to create identity providers for %s", organization.getId()));
119+
}
120+
121+
idpDefaults(representation, Optional.empty());
122+
deactivateOtherIdps(representation);
94123

95124
Response resp = getIdpResource().create(representation);
96125
if (resp.getStatus() == Response.Status.CREATED.getStatusCode()) {
97-
return Response.created(
98-
session
99-
.getContext()
100-
.getUri()
101-
.getAbsolutePathBuilder()
102-
.path(representation.getAlias())
103-
.build())
104-
.build();
126+
return createdResponse(representation);
127+
} else {
128+
return resp;
129+
}
130+
}
131+
132+
@POST
133+
@Path("link")
134+
@Consumes(MediaType.APPLICATION_JSON)
135+
@Produces(MediaType.APPLICATION_JSON)
136+
public Response linkIdp(LinkIdp linkIdp) {
137+
// authz
138+
if (!auth.hasManageOrgs() && !auth.hasOrgManageIdentityProviders(organization)) {
139+
throw new NotAuthorizedException(
140+
String.format(
141+
"Insufficient permission to create identity providers for %s", organization.getId()));
142+
}
143+
144+
// get an idp with the same alias
145+
IdentityProviderModel idp = realm.getIdentityProviderByAlias(linkIdp.getAlias());
146+
if (idp == null) {
147+
throw new NotFoundException(String.format("No IdP found with alias %s", linkIdp.getAlias()));
148+
}
149+
150+
// does it already contain an orgId for a different org?
151+
String orgId = idp.getConfig().get(ORG_OWNER_CONFIG_KEY);
152+
if (orgId != null && !organization.getId().equals(orgId)) {
153+
throw new ClientErrorException(
154+
String.format("IdP with alias %s unavailable for linking.", linkIdp.getAlias()),
155+
Response.Status.CONFLICT);
156+
}
157+
158+
IdentityProviderRepresentation representation =
159+
ModelToRepresentation.toRepresentation(realm, idp);
160+
idpDefaults(representation, Optional.of(linkIdp));
161+
if (!Strings.isNullOrEmpty(linkIdp.getSyncMode())) {
162+
representation.getConfig().put("syncMode", linkIdp.getSyncMode());
163+
}
164+
if (!Strings.isNullOrEmpty(linkIdp.getPostBrokerFlow())) {
165+
representation.setPostBrokerLoginFlowAlias(linkIdp.getPostBrokerFlow());
166+
}
167+
deactivateOtherIdps(representation);
168+
169+
Response resp = getIdpResource().getIdentityProvider(linkIdp.getAlias()).update(representation);
170+
if (resp.getStatus() == Response.Status.NO_CONTENT.getStatusCode()) {
171+
return createdResponse(representation);
105172
} else {
106173
return resp;
107174
}

src/test/java/io/phasetwo/service/resource/OrganizationResourceTest.java

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
import com.google.common.collect.ImmutableMap;
1212
import io.phasetwo.client.openapi.model.*;
1313
import io.phasetwo.service.AbstractOrganizationTest;
14+
import io.phasetwo.service.representation.LinkIdp;
1415
import io.restassured.http.Header;
1516
import io.restassured.response.Response;
1617
import jakarta.ws.rs.core.MediaType;
@@ -674,6 +675,66 @@ void testListOrgsByMember() throws IOException {
674675
}
675676
}
676677

678+
@Test
679+
void testLinkIdp() throws IOException {
680+
OrganizationRepresentation org = createDefaultOrg();
681+
String id = org.getId();
682+
683+
String alias1 = "linking-provider-1";
684+
org.keycloak.representations.idm.IdentityProviderRepresentation idp =
685+
new org.keycloak.representations.idm.IdentityProviderRepresentation();
686+
idp.setAlias(alias1);
687+
idp.setProviderId("oidc");
688+
idp.setEnabled(true);
689+
idp.setFirstBrokerLoginFlowAlias("first broker login");
690+
idp.setConfig(
691+
new ImmutableMap.Builder<String, String>()
692+
.put("useJwksUrl", "true")
693+
.put("syncMode", "FORCE")
694+
.put("authorizationUrl", "https://foo.com")
695+
.put("hideOnLoginPage", "")
696+
.put("loginHint", "")
697+
.put("uiLocales", "")
698+
.put("backchannelSupported", "")
699+
.put("disableUserInfo", "")
700+
.put("acceptsPromptNoneForwardFromClient", "")
701+
.put("validateSignature", "")
702+
.put("pkceEnabled", "")
703+
.put("tokenUrl", "https://foo.com")
704+
.put("clientAuthMethod", "client_secret_post")
705+
.put("clientId", "aabbcc")
706+
.put("clientSecret", "112233")
707+
.build());
708+
keycloak.realm(REALM).identityProviders().create(idp);
709+
710+
// link it
711+
LinkIdp link = new LinkIdp();
712+
link.setAlias(alias1);
713+
link.setSyncMode("IMPORT");
714+
Response response = postRequest(link, org.getId(), "idps", "link");
715+
assertThat(response.getStatusCode(), is(Status.CREATED.getStatusCode()));
716+
717+
// get it
718+
response = getRequest(id, "idps");
719+
assertThat(response.getStatusCode(), is(Status.OK.getStatusCode()));
720+
List<IdentityProviderRepresentation> idps =
721+
new ObjectMapper().readValue(response.getBody().asString(), new TypeReference<>() {});
722+
assertThat(idps, notNullValue());
723+
assertThat(idps, hasSize(1));
724+
725+
IdentityProviderRepresentation representation = idps.get(0);
726+
assertThat(representation.getEnabled(), is(true));
727+
assertThat(representation.getAlias(), is(alias1));
728+
assertThat(representation.getProviderId(), is("oidc"));
729+
assertThat(representation.getConfig().get("syncMode"), is("IMPORT"));
730+
731+
// delete org
732+
deleteOrganization(id);
733+
734+
// delete idp
735+
keycloak.realm(REALM).identityProviders().get(alias1).remove();
736+
}
737+
677738
@Test
678739
void testAddGetDeleteIdps() throws IOException {
679740
OrganizationRepresentation org = createDefaultOrg();

0 commit comments

Comments
 (0)