feat: replace tenant OIDC page with Enterprise SSO connector management
- Add LogtoManagementClient methods for SSO connector CRUD + org JIT - Add TenantSsoService with tenant isolation (validates connector-org link) - Add TenantSsoController at /api/tenant/sso with test endpoint - Create SsoPage with provider selection, dynamic config form, test button - Remove old OIDC config endpoints from tenant portal (server OIDC is now platform-managed, set during provisioning) - Sidebar: OIDC -> SSO with Shield icon Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -286,6 +286,118 @@ public class LogtoManagementClient {
|
||||
}
|
||||
}
|
||||
|
||||
// --- SSO Connector Management ---
|
||||
|
||||
/** List all SSO connectors. */
|
||||
@SuppressWarnings("unchecked")
|
||||
public List<Map<String, Object>> listSsoConnectors() {
|
||||
if (!isAvailable()) return List.of();
|
||||
try {
|
||||
var resp = restClient.get()
|
||||
.uri(config.getLogtoEndpoint() + "/api/sso-connectors?page=1&page_size=100")
|
||||
.header("Authorization", "Bearer " + getAccessToken())
|
||||
.retrieve()
|
||||
.body(List.class);
|
||||
return resp != null ? resp : List.of();
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to list SSO connectors: {}", e.getMessage());
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
|
||||
/** Create an SSO connector. */
|
||||
@SuppressWarnings("unchecked")
|
||||
public Map<String, Object> createSsoConnector(String providerName, String connectorName,
|
||||
Map<String, Object> connectorConfig, List<String> domains) {
|
||||
if (!isAvailable()) return null;
|
||||
var body = new java.util.HashMap<String, Object>();
|
||||
body.put("providerName", providerName);
|
||||
body.put("connectorName", connectorName);
|
||||
if (connectorConfig != null && !connectorConfig.isEmpty()) body.put("config", connectorConfig);
|
||||
if (domains != null && !domains.isEmpty()) body.put("domains", domains);
|
||||
|
||||
return (Map<String, Object>) restClient.post()
|
||||
.uri(config.getLogtoEndpoint() + "/api/sso-connectors")
|
||||
.header("Authorization", "Bearer " + getAccessToken())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(body)
|
||||
.retrieve()
|
||||
.body(Map.class);
|
||||
}
|
||||
|
||||
/** Get an SSO connector by ID. */
|
||||
@SuppressWarnings("unchecked")
|
||||
public Map<String, Object> getSsoConnector(String connectorId) {
|
||||
if (!isAvailable()) return null;
|
||||
return (Map<String, Object>) restClient.get()
|
||||
.uri(config.getLogtoEndpoint() + "/api/sso-connectors/" + connectorId)
|
||||
.header("Authorization", "Bearer " + getAccessToken())
|
||||
.retrieve()
|
||||
.body(Map.class);
|
||||
}
|
||||
|
||||
/** Update an SSO connector (partial update). */
|
||||
@SuppressWarnings("unchecked")
|
||||
public Map<String, Object> updateSsoConnector(String connectorId, Map<String, Object> updates) {
|
||||
if (!isAvailable()) return null;
|
||||
return (Map<String, Object>) restClient.patch()
|
||||
.uri(config.getLogtoEndpoint() + "/api/sso-connectors/" + connectorId)
|
||||
.header("Authorization", "Bearer " + getAccessToken())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(updates)
|
||||
.retrieve()
|
||||
.body(Map.class);
|
||||
}
|
||||
|
||||
/** Delete an SSO connector. */
|
||||
public void deleteSsoConnector(String connectorId) {
|
||||
if (!isAvailable()) return;
|
||||
restClient.delete()
|
||||
.uri(config.getLogtoEndpoint() + "/api/sso-connectors/" + connectorId)
|
||||
.header("Authorization", "Bearer " + getAccessToken())
|
||||
.retrieve()
|
||||
.toBodilessEntity();
|
||||
}
|
||||
|
||||
/** List SSO connectors linked to an organization via JIT provisioning. */
|
||||
@SuppressWarnings("unchecked")
|
||||
public List<Map<String, Object>> getOrgJitSsoConnectors(String orgId) {
|
||||
if (!isAvailable()) return List.of();
|
||||
try {
|
||||
var resp = restClient.get()
|
||||
.uri(config.getLogtoEndpoint() + "/api/organizations/" + orgId + "/jit/sso-connectors?page=1&page_size=100")
|
||||
.header("Authorization", "Bearer " + getAccessToken())
|
||||
.retrieve()
|
||||
.body(List.class);
|
||||
return resp != null ? resp : List.of();
|
||||
} catch (Exception e) {
|
||||
log.warn("Failed to list org JIT SSO connectors for {}: {}", orgId, e.getMessage());
|
||||
return List.of();
|
||||
}
|
||||
}
|
||||
|
||||
/** Link an SSO connector to an organization for JIT provisioning. */
|
||||
public void linkSsoConnectorToOrg(String orgId, String connectorId) {
|
||||
if (!isAvailable()) return;
|
||||
restClient.post()
|
||||
.uri(config.getLogtoEndpoint() + "/api/organizations/" + orgId + "/jit/sso-connectors")
|
||||
.header("Authorization", "Bearer " + getAccessToken())
|
||||
.contentType(MediaType.APPLICATION_JSON)
|
||||
.body(Map.of("ssoConnectorIds", List.of(connectorId)))
|
||||
.retrieve()
|
||||
.toBodilessEntity();
|
||||
}
|
||||
|
||||
/** Unlink an SSO connector from an organization's JIT provisioning. */
|
||||
public void unlinkSsoConnectorFromOrg(String orgId, String connectorId) {
|
||||
if (!isAvailable()) return;
|
||||
restClient.delete()
|
||||
.uri(config.getLogtoEndpoint() + "/api/organizations/" + orgId + "/jit/sso-connectors/" + connectorId)
|
||||
.header("Authorization", "Bearer " + getAccessToken())
|
||||
.retrieve()
|
||||
.toBodilessEntity();
|
||||
}
|
||||
|
||||
private static final String MGMT_API_RESOURCE = "https://default.logto.app/api";
|
||||
|
||||
private synchronized String getAccessToken() {
|
||||
|
||||
@@ -46,17 +46,6 @@ public class TenantPortalController {
|
||||
return ResponseEntity.ok(license);
|
||||
}
|
||||
|
||||
@GetMapping("/oidc")
|
||||
public ResponseEntity<Map<String, Object>> getOidcConfig() {
|
||||
return ResponseEntity.ok(portalService.getOidcConfig());
|
||||
}
|
||||
|
||||
@PutMapping("/oidc")
|
||||
public ResponseEntity<Void> updateOidcConfig(@RequestBody Map<String, Object> body) {
|
||||
portalService.updateOidcConfig(body);
|
||||
return ResponseEntity.ok().build();
|
||||
}
|
||||
|
||||
@GetMapping("/team")
|
||||
public ResponseEntity<List<Map<String, Object>>> listTeamMembers() {
|
||||
return ResponseEntity.ok(portalService.listTeamMembers());
|
||||
|
||||
@@ -116,24 +116,6 @@ public class TenantPortalService {
|
||||
.orElse(null);
|
||||
}
|
||||
|
||||
public Map<String, Object> getOidcConfig() {
|
||||
TenantEntity tenant = resolveTenant();
|
||||
String endpoint = tenant.getServerEndpoint();
|
||||
if (endpoint == null || endpoint.isBlank()) {
|
||||
return Map.of();
|
||||
}
|
||||
return serverApiClient.getOidcConfig(endpoint);
|
||||
}
|
||||
|
||||
public void updateOidcConfig(Map<String, Object> config) {
|
||||
TenantEntity tenant = resolveTenant();
|
||||
String endpoint = tenant.getServerEndpoint();
|
||||
if (endpoint == null || endpoint.isBlank()) {
|
||||
throw new IllegalStateException("Tenant has no server endpoint configured");
|
||||
}
|
||||
serverApiClient.pushOidcConfig(endpoint, config);
|
||||
}
|
||||
|
||||
public List<Map<String, Object>> listTeamMembers() {
|
||||
TenantEntity tenant = resolveTenant();
|
||||
String orgId = tenant.getLogtoOrgId();
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
package net.siegeln.cameleer.saas.portal;
|
||||
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.DeleteMapping;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PatchMapping;
|
||||
import org.springframework.web.bind.annotation.PathVariable;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/tenant/sso")
|
||||
public class TenantSsoController {
|
||||
|
||||
private final TenantSsoService ssoService;
|
||||
|
||||
public TenantSsoController(TenantSsoService ssoService) {
|
||||
this.ssoService = ssoService;
|
||||
}
|
||||
|
||||
public record CreateSsoConnectorRequest(
|
||||
String providerName,
|
||||
String connectorName,
|
||||
Map<String, Object> config,
|
||||
List<String> domains
|
||||
) {}
|
||||
|
||||
@GetMapping
|
||||
public ResponseEntity<List<Map<String, Object>>> list() {
|
||||
return ResponseEntity.ok(ssoService.listConnectors());
|
||||
}
|
||||
|
||||
@PostMapping
|
||||
public ResponseEntity<Map<String, Object>> create(@RequestBody CreateSsoConnectorRequest request) {
|
||||
var connector = ssoService.createConnector(
|
||||
request.providerName(), request.connectorName(),
|
||||
request.config(), request.domains());
|
||||
return ResponseEntity.status(HttpStatus.CREATED).body(connector);
|
||||
}
|
||||
|
||||
@GetMapping("/{connectorId}")
|
||||
public ResponseEntity<Map<String, Object>> get(@PathVariable String connectorId) {
|
||||
return ResponseEntity.ok(ssoService.getConnector(connectorId));
|
||||
}
|
||||
|
||||
@PatchMapping("/{connectorId}")
|
||||
public ResponseEntity<Map<String, Object>> update(@PathVariable String connectorId,
|
||||
@RequestBody Map<String, Object> updates) {
|
||||
return ResponseEntity.ok(ssoService.updateConnector(connectorId, updates));
|
||||
}
|
||||
|
||||
@DeleteMapping("/{connectorId}")
|
||||
public ResponseEntity<Void> delete(@PathVariable String connectorId) {
|
||||
ssoService.deleteConnector(connectorId);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
|
||||
@PostMapping("/{connectorId}/test")
|
||||
public ResponseEntity<Map<String, Object>> test(@PathVariable String connectorId) {
|
||||
return ResponseEntity.ok(ssoService.testConnector(connectorId));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package net.siegeln.cameleer.saas.portal;
|
||||
|
||||
import net.siegeln.cameleer.saas.config.TenantContext;
|
||||
import net.siegeln.cameleer.saas.identity.LogtoManagementClient;
|
||||
import net.siegeln.cameleer.saas.tenant.TenantEntity;
|
||||
import net.siegeln.cameleer.saas.tenant.TenantService;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
import org.springframework.http.HttpStatus;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Set;
|
||||
import java.util.UUID;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
@Service
|
||||
public class TenantSsoService {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(TenantSsoService.class);
|
||||
|
||||
private final LogtoManagementClient logtoClient;
|
||||
private final TenantService tenantService;
|
||||
|
||||
public TenantSsoService(LogtoManagementClient logtoClient, TenantService tenantService) {
|
||||
this.logtoClient = logtoClient;
|
||||
this.tenantService = tenantService;
|
||||
}
|
||||
|
||||
/** List SSO connectors linked to the current tenant's organization. */
|
||||
@SuppressWarnings("unchecked")
|
||||
public List<Map<String, Object>> listConnectors() {
|
||||
String orgId = resolveOrgId();
|
||||
List<Map<String, Object>> jitConnectors = logtoClient.getOrgJitSsoConnectors(orgId);
|
||||
Set<String> linkedIds = jitConnectors.stream()
|
||||
.map(c -> String.valueOf(c.get("id")))
|
||||
.collect(Collectors.toSet());
|
||||
|
||||
if (linkedIds.isEmpty()) return List.of();
|
||||
|
||||
// Enrich with full connector details
|
||||
List<Map<String, Object>> allConnectors = logtoClient.listSsoConnectors();
|
||||
return allConnectors.stream()
|
||||
.filter(c -> linkedIds.contains(String.valueOf(c.get("id"))))
|
||||
.toList();
|
||||
}
|
||||
|
||||
/** Create an SSO connector and link it to the tenant's organization. */
|
||||
public Map<String, Object> createConnector(String providerName, String connectorName,
|
||||
Map<String, Object> config, List<String> domains) {
|
||||
String orgId = resolveOrgId();
|
||||
var connector = logtoClient.createSsoConnector(providerName, connectorName, config, domains);
|
||||
if (connector == null) {
|
||||
throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR, "Failed to create SSO connector");
|
||||
}
|
||||
String connectorId = String.valueOf(connector.get("id"));
|
||||
logtoClient.linkSsoConnectorToOrg(orgId, connectorId);
|
||||
log.info("Created SSO connector '{}' ({}) and linked to org {}", connectorName, connectorId, orgId);
|
||||
return connector;
|
||||
}
|
||||
|
||||
/** Get a single SSO connector (validates it belongs to this tenant). */
|
||||
public Map<String, Object> getConnector(String connectorId) {
|
||||
validateConnectorBelongsToTenant(connectorId);
|
||||
return logtoClient.getSsoConnector(connectorId);
|
||||
}
|
||||
|
||||
/** Update an SSO connector (validates it belongs to this tenant). */
|
||||
public Map<String, Object> updateConnector(String connectorId, Map<String, Object> updates) {
|
||||
validateConnectorBelongsToTenant(connectorId);
|
||||
return logtoClient.updateSsoConnector(connectorId, updates);
|
||||
}
|
||||
|
||||
/** Delete an SSO connector (unlinks from org and deletes). */
|
||||
public void deleteConnector(String connectorId) {
|
||||
String orgId = resolveOrgId();
|
||||
validateConnectorBelongsToTenant(connectorId);
|
||||
logtoClient.unlinkSsoConnectorFromOrg(orgId, connectorId);
|
||||
logtoClient.deleteSsoConnector(connectorId);
|
||||
log.info("Deleted SSO connector {} from org {}", connectorId, orgId);
|
||||
}
|
||||
|
||||
/** Test an SSO connector by fetching its details (validates provider metadata). */
|
||||
public Map<String, Object> testConnector(String connectorId) {
|
||||
validateConnectorBelongsToTenant(connectorId);
|
||||
var connector = logtoClient.getSsoConnector(connectorId);
|
||||
if (connector == null) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Connector not found");
|
||||
}
|
||||
// Logto resolves providerConfig (OIDC discovery / SAML metadata) when fetching.
|
||||
// If providerConfig is present and non-empty, the IdP is reachable.
|
||||
@SuppressWarnings("unchecked")
|
||||
var providerConfig = (Map<String, Object>) connector.get("providerConfig");
|
||||
boolean reachable = providerConfig != null && !providerConfig.isEmpty();
|
||||
return Map.of(
|
||||
"status", reachable ? "ok" : "unreachable",
|
||||
"providerName", String.valueOf(connector.get("providerName")),
|
||||
"connectorName", String.valueOf(connector.get("connectorName"))
|
||||
);
|
||||
}
|
||||
|
||||
private String resolveOrgId() {
|
||||
UUID tenantId = TenantContext.getTenantId();
|
||||
TenantEntity tenant = tenantService.getById(tenantId)
|
||||
.orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "Tenant not found"));
|
||||
String orgId = tenant.getLogtoOrgId();
|
||||
if (orgId == null || orgId.isBlank()) {
|
||||
throw new ResponseStatusException(HttpStatus.PRECONDITION_FAILED, "Tenant has no Logto organization");
|
||||
}
|
||||
return orgId;
|
||||
}
|
||||
|
||||
private void validateConnectorBelongsToTenant(String connectorId) {
|
||||
String orgId = resolveOrgId();
|
||||
List<Map<String, Object>> jitConnectors = logtoClient.getOrgJitSsoConnectors(orgId);
|
||||
boolean linked = jitConnectors.stream()
|
||||
.anyMatch(c -> connectorId.equals(String.valueOf(c.get("id"))));
|
||||
if (!linked) {
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN, "SSO connector does not belong to this tenant");
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user