Logto's /api/roles/{id}/users endpoint rejects page=1 with
guard.invalid_pagination. Remove explicit pagination params and
let Logto use its defaults.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
784 lines
32 KiB
Java
784 lines
32 KiB
Java
package net.siegeln.cameleer.saas.identity;
|
|
|
|
import com.fasterxml.jackson.databind.JsonNode;
|
|
import org.slf4j.Logger;
|
|
import org.slf4j.LoggerFactory;
|
|
import org.springframework.http.MediaType;
|
|
import org.springframework.stereotype.Service;
|
|
import org.springframework.web.client.RestClient;
|
|
|
|
import java.time.Instant;
|
|
import java.util.ArrayList;
|
|
import java.util.List;
|
|
import java.util.Map;
|
|
|
|
@Service
|
|
public class LogtoManagementClient {
|
|
|
|
private static final Logger log = LoggerFactory.getLogger(LogtoManagementClient.class);
|
|
|
|
private final LogtoConfig config;
|
|
private final RestClient restClient;
|
|
|
|
private String cachedToken;
|
|
private Instant tokenExpiry = Instant.MIN;
|
|
|
|
public LogtoManagementClient(LogtoConfig config) {
|
|
this.config = config;
|
|
this.restClient = RestClient.builder()
|
|
.defaultHeader("Content-Type", "application/json")
|
|
.build();
|
|
}
|
|
|
|
public boolean isAvailable() {
|
|
return config.isConfigured();
|
|
}
|
|
|
|
public String createOrganization(String name, String description) {
|
|
if (!isAvailable()) {
|
|
log.warn("Logto not configured — skipping organization creation for '{}'", name);
|
|
return null;
|
|
}
|
|
|
|
var body = Map.of("name", name, "description", description != null ? description : "");
|
|
|
|
var response = restClient.post()
|
|
.uri(config.getLogtoEndpoint() + "/api/organizations")
|
|
.header("Authorization", "Bearer " + getAccessToken())
|
|
.contentType(MediaType.APPLICATION_JSON)
|
|
.body(body)
|
|
.retrieve()
|
|
.body(JsonNode.class);
|
|
|
|
return response != null ? response.get("id").asText() : null;
|
|
}
|
|
|
|
public void addUserToOrganization(String orgId, String userId) {
|
|
if (!isAvailable()) return;
|
|
|
|
restClient.post()
|
|
.uri(config.getLogtoEndpoint() + "/api/organizations/" + orgId + "/users")
|
|
.header("Authorization", "Bearer " + getAccessToken())
|
|
.contentType(MediaType.APPLICATION_JSON)
|
|
.body(Map.of("userIds", new String[]{userId}))
|
|
.retrieve()
|
|
.toBodilessEntity();
|
|
}
|
|
|
|
public void deleteOrganization(String orgId) {
|
|
if (!isAvailable()) return;
|
|
|
|
restClient.delete()
|
|
.uri(config.getLogtoEndpoint() + "/api/organizations/" + orgId)
|
|
.header("Authorization", "Bearer " + getAccessToken())
|
|
.retrieve()
|
|
.toBodilessEntity();
|
|
}
|
|
|
|
/** Add redirect URIs to a Logto application (for OIDC callback registration). */
|
|
@SuppressWarnings("unchecked")
|
|
public void addAppRedirectUris(String appId, List<String> redirectUris, List<String> postLogoutUris) {
|
|
if (!isAvailable() || appId == null) return;
|
|
try {
|
|
String token = getAccessToken();
|
|
// GET current app config
|
|
var app = (Map<String, Object>) restClient.get()
|
|
.uri(config.getLogtoEndpoint() + "/api/applications/" + appId)
|
|
.header("Authorization", "Bearer " + token)
|
|
.retrieve()
|
|
.body(Map.class);
|
|
if (app == null) return;
|
|
|
|
var metadata = (Map<String, Object>) app.get("oidcClientMetadata");
|
|
if (metadata == null) return;
|
|
|
|
// Merge new URIs with existing
|
|
var existingRedirects = new ArrayList<>((List<String>) metadata.getOrDefault("redirectUris", List.of()));
|
|
var existingPostLogout = new ArrayList<>((List<String>) metadata.getOrDefault("postLogoutRedirectUris", List.of()));
|
|
for (String uri : redirectUris) {
|
|
if (!existingRedirects.contains(uri)) existingRedirects.add(uri);
|
|
}
|
|
for (String uri : postLogoutUris) {
|
|
if (!existingPostLogout.contains(uri)) existingPostLogout.add(uri);
|
|
}
|
|
|
|
// PATCH app with updated URIs
|
|
restClient.patch()
|
|
.uri(config.getLogtoEndpoint() + "/api/applications/" + appId)
|
|
.header("Authorization", "Bearer " + token)
|
|
.contentType(MediaType.APPLICATION_JSON)
|
|
.body(Map.of("oidcClientMetadata", Map.of(
|
|
"redirectUris", existingRedirects,
|
|
"postLogoutRedirectUris", existingPostLogout
|
|
)))
|
|
.retrieve()
|
|
.toBodilessEntity();
|
|
log.info("Updated redirect URIs for app {}: added {}", appId, redirectUris);
|
|
} catch (Exception e) {
|
|
log.warn("Failed to update redirect URIs for app {}: {}", appId, e.getMessage());
|
|
}
|
|
}
|
|
|
|
public List<Map<String, String>> getUserOrganizations(String userId) {
|
|
if (!isAvailable()) return List.of();
|
|
|
|
try {
|
|
var response = restClient.get()
|
|
.uri(config.getLogtoEndpoint() + "/api/users/" + userId + "/organizations")
|
|
.header("Authorization", "Bearer " + getAccessToken())
|
|
.retrieve()
|
|
.body(JsonNode.class);
|
|
|
|
List<Map<String, String>> orgs = new ArrayList<>();
|
|
if (response != null && response.isArray()) {
|
|
for (var node : response) {
|
|
orgs.add(Map.of(
|
|
"id", node.get("id").asText(),
|
|
"name", node.get("name").asText()
|
|
));
|
|
}
|
|
}
|
|
return orgs;
|
|
} catch (Exception e) {
|
|
log.warn("Failed to get user organizations for {}: {}", userId, e.getMessage());
|
|
return List.of();
|
|
}
|
|
}
|
|
|
|
/** List members of a Logto organization. */
|
|
@SuppressWarnings("unchecked")
|
|
public List<Map<String, Object>> listOrganizationMembers(String orgId) {
|
|
if (!isAvailable()) return List.of();
|
|
try {
|
|
var resp = restClient.get()
|
|
.uri(config.getLogtoEndpoint() + "/api/organizations/" + orgId + "/users")
|
|
.header("Authorization", "Bearer " + getAccessToken())
|
|
.retrieve()
|
|
.body(List.class);
|
|
return resp != null ? resp : List.of();
|
|
} catch (Exception e) {
|
|
log.warn("Failed to list org members for {}: {}", orgId, e.getMessage());
|
|
return List.of();
|
|
}
|
|
}
|
|
|
|
/** Get roles assigned to a user within an organization. */
|
|
@SuppressWarnings("unchecked")
|
|
public List<Map<String, Object>> getUserOrganizationRoles(String orgId, String userId) {
|
|
if (!isAvailable()) return List.of();
|
|
try {
|
|
var resp = restClient.get()
|
|
.uri(config.getLogtoEndpoint() + "/api/organizations/" + orgId + "/users/" + userId + "/roles")
|
|
.header("Authorization", "Bearer " + getAccessToken())
|
|
.retrieve()
|
|
.body(List.class);
|
|
return resp != null ? resp : List.of();
|
|
} catch (Exception e) {
|
|
log.warn("Failed to get user roles: {}", e.getMessage());
|
|
return List.of();
|
|
}
|
|
}
|
|
|
|
/** Assign a role to a user in an organization. */
|
|
public void assignOrganizationRole(String orgId, String userId, String roleId) {
|
|
if (!isAvailable()) return;
|
|
try {
|
|
restClient.post()
|
|
.uri(config.getLogtoEndpoint() + "/api/organizations/" + orgId + "/users/" + userId + "/roles")
|
|
.header("Authorization", "Bearer " + getAccessToken())
|
|
.contentType(MediaType.APPLICATION_JSON)
|
|
.body(Map.of("organizationRoleIds", List.of(roleId)))
|
|
.retrieve()
|
|
.toBodilessEntity();
|
|
} catch (Exception e) {
|
|
log.warn("Failed to assign role: {}", e.getMessage());
|
|
}
|
|
}
|
|
|
|
/** Remove a user from an organization. */
|
|
public void removeUserFromOrganization(String orgId, String userId) {
|
|
if (!isAvailable()) return;
|
|
try {
|
|
restClient.delete()
|
|
.uri(config.getLogtoEndpoint() + "/api/organizations/" + orgId + "/users/" + userId)
|
|
.header("Authorization", "Bearer " + getAccessToken())
|
|
.retrieve()
|
|
.toBodilessEntity();
|
|
} catch (Exception e) {
|
|
log.warn("Failed to remove user from org: {}", e.getMessage());
|
|
}
|
|
}
|
|
|
|
/** Create a user in Logto and add to organization with role. */
|
|
@SuppressWarnings("unchecked")
|
|
public String createAndInviteUser(String email, String orgId, String roleId) {
|
|
if (!isAvailable()) return null;
|
|
try {
|
|
var userResp = (Map<String, Object>) restClient.post()
|
|
.uri(config.getLogtoEndpoint() + "/api/users")
|
|
.header("Authorization", "Bearer " + getAccessToken())
|
|
.contentType(MediaType.APPLICATION_JSON)
|
|
.body(Map.of("primaryEmail", email, "name", email.split("@")[0]))
|
|
.retrieve()
|
|
.body(Map.class);
|
|
String userId = String.valueOf(userResp.get("id"));
|
|
if (orgId != null) {
|
|
addUserToOrganization(orgId, userId);
|
|
if (roleId != null) {
|
|
assignOrganizationRole(orgId, userId, roleId);
|
|
}
|
|
}
|
|
return userId;
|
|
} catch (Exception e) {
|
|
log.error("Failed to create and invite user: {}", e.getMessage());
|
|
throw new RuntimeException("Invite failed: " + e.getMessage(), e);
|
|
}
|
|
}
|
|
|
|
/** Create a user with username/password and optionally add to org with role. */
|
|
@SuppressWarnings("unchecked")
|
|
public String createUserWithPassword(String username, String password, String orgId, String roleId) {
|
|
if (!isAvailable()) return null;
|
|
try {
|
|
var userResp = (Map<String, Object>) restClient.post()
|
|
.uri(config.getLogtoEndpoint() + "/api/users")
|
|
.header("Authorization", "Bearer " + getAccessToken())
|
|
.contentType(MediaType.APPLICATION_JSON)
|
|
.body(Map.of("username", username, "password", password, "name", username))
|
|
.retrieve()
|
|
.body(Map.class);
|
|
String userId = String.valueOf(userResp.get("id"));
|
|
if (orgId != null) {
|
|
addUserToOrganization(orgId, userId);
|
|
if (roleId != null) {
|
|
assignOrganizationRole(orgId, userId, roleId);
|
|
}
|
|
}
|
|
log.info("Created user '{}' and added to org {} with role {}", username, orgId, roleId);
|
|
return userId;
|
|
} catch (Exception e) {
|
|
log.error("Failed to create user '{}': {}", username, e.getMessage());
|
|
throw new RuntimeException("User creation failed: " + e.getMessage(), e);
|
|
}
|
|
}
|
|
|
|
/** Find org role ID by name (e.g., "owner", "operator", "viewer"). */
|
|
@SuppressWarnings("unchecked")
|
|
public String findOrgRoleIdByName(String roleName) {
|
|
var roles = listOrganizationRoles();
|
|
return roles.stream()
|
|
.filter(r -> roleName.equals(r.get("name")))
|
|
.map(r -> String.valueOf(r.get("id")))
|
|
.findFirst()
|
|
.orElse(null);
|
|
}
|
|
|
|
/** List available organization roles. */
|
|
@SuppressWarnings("unchecked")
|
|
public List<Map<String, Object>> listOrganizationRoles() {
|
|
if (!isAvailable()) return List.of();
|
|
try {
|
|
var resp = restClient.get()
|
|
.uri(config.getLogtoEndpoint() + "/api/organization-roles")
|
|
.header("Authorization", "Bearer " + getAccessToken())
|
|
.retrieve()
|
|
.body(List.class);
|
|
return resp != null ? resp : List.of();
|
|
} catch (Exception e) {
|
|
log.warn("Failed to list org roles: {}", e.getMessage());
|
|
return List.of();
|
|
}
|
|
}
|
|
|
|
// --- 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();
|
|
}
|
|
|
|
// --- Email Connector Management ---
|
|
|
|
/** List all connector factories available in Logto. */
|
|
@SuppressWarnings("unchecked")
|
|
public List<Map<String, Object>> listConnectorFactories() {
|
|
if (!isAvailable()) return List.of();
|
|
try {
|
|
var resp = restClient.get()
|
|
.uri(config.getLogtoEndpoint() + "/api/connector-factories")
|
|
.header("Authorization", "Bearer " + getAccessToken())
|
|
.retrieve()
|
|
.body(List.class);
|
|
return resp != null ? resp : List.of();
|
|
} catch (Exception e) {
|
|
log.warn("Failed to list connector factories: {}", e.getMessage());
|
|
return List.of();
|
|
}
|
|
}
|
|
|
|
/** List all connectors. */
|
|
@SuppressWarnings("unchecked")
|
|
public List<Map<String, Object>> listConnectors() {
|
|
if (!isAvailable()) return List.of();
|
|
try {
|
|
var resp = restClient.get()
|
|
.uri(config.getLogtoEndpoint() + "/api/connectors")
|
|
.header("Authorization", "Bearer " + getAccessToken())
|
|
.retrieve()
|
|
.body(List.class);
|
|
return resp != null ? resp : List.of();
|
|
} catch (Exception e) {
|
|
log.warn("Failed to list connectors: {}", e.getMessage());
|
|
return List.of();
|
|
}
|
|
}
|
|
|
|
/** Create a connector from a factory. */
|
|
@SuppressWarnings("unchecked")
|
|
public Map<String, Object> createConnector(String factoryId, Map<String, Object> connectorConfig) {
|
|
if (!isAvailable()) return null;
|
|
var body = new java.util.HashMap<String, Object>();
|
|
body.put("connectorId", factoryId);
|
|
body.put("config", connectorConfig);
|
|
return (Map<String, Object>) restClient.post()
|
|
.uri(config.getLogtoEndpoint() + "/api/connectors")
|
|
.header("Authorization", "Bearer " + getAccessToken())
|
|
.contentType(MediaType.APPLICATION_JSON)
|
|
.body(body)
|
|
.retrieve()
|
|
.body(Map.class);
|
|
}
|
|
|
|
/** Update an existing connector's config. */
|
|
@SuppressWarnings("unchecked")
|
|
public Map<String, Object> updateConnector(String connectorId, Map<String, Object> connectorConfig) {
|
|
if (!isAvailable()) return null;
|
|
return (Map<String, Object>) restClient.patch()
|
|
.uri(config.getLogtoEndpoint() + "/api/connectors/" + connectorId)
|
|
.header("Authorization", "Bearer " + getAccessToken())
|
|
.contentType(MediaType.APPLICATION_JSON)
|
|
.body(Map.of("config", connectorConfig))
|
|
.retrieve()
|
|
.body(Map.class);
|
|
}
|
|
|
|
/** Delete a connector. */
|
|
public void deleteConnector(String connectorId) {
|
|
if (!isAvailable()) return;
|
|
restClient.delete()
|
|
.uri(config.getLogtoEndpoint() + "/api/connectors/" + connectorId)
|
|
.header("Authorization", "Bearer " + getAccessToken())
|
|
.retrieve()
|
|
.toBodilessEntity();
|
|
}
|
|
|
|
/** Test a connector by sending a real email. Uses Logto's built-in test endpoint. */
|
|
public void testConnector(String factoryId, String email, Map<String, Object> connectorConfig) {
|
|
if (!isAvailable()) throw new IllegalStateException("Logto not configured");
|
|
restClient.post()
|
|
.uri(config.getLogtoEndpoint() + "/api/connectors/" + factoryId + "/test")
|
|
.header("Authorization", "Bearer " + getAccessToken())
|
|
.contentType(MediaType.APPLICATION_JSON)
|
|
.body(Map.of("email", email, "config", connectorConfig))
|
|
.retrieve()
|
|
.toBodilessEntity();
|
|
}
|
|
|
|
/** Get the current sign-in experience config. */
|
|
@SuppressWarnings("unchecked")
|
|
public Map<String, Object> getSignInExperience() {
|
|
if (!isAvailable()) return null;
|
|
try {
|
|
return (Map<String, Object>) restClient.get()
|
|
.uri(config.getLogtoEndpoint() + "/api/sign-in-exp")
|
|
.header("Authorization", "Bearer " + getAccessToken())
|
|
.retrieve()
|
|
.body(Map.class);
|
|
} catch (Exception e) {
|
|
log.warn("Failed to get sign-in experience: {}", e.getMessage());
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/** Update the sign-in experience config (partial update). */
|
|
@SuppressWarnings("unchecked")
|
|
public Map<String, Object> updateSignInExperience(Map<String, Object> updates) {
|
|
if (!isAvailable()) return null;
|
|
return (Map<String, Object>) restClient.patch()
|
|
.uri(config.getLogtoEndpoint() + "/api/sign-in-exp")
|
|
.header("Authorization", "Bearer " + getAccessToken())
|
|
.contentType(MediaType.APPLICATION_JSON)
|
|
.body(updates)
|
|
.retrieve()
|
|
.body(Map.class);
|
|
}
|
|
|
|
/** Update a user's password. */
|
|
public void updateUserPassword(String userId, String newPassword) {
|
|
if (!isAvailable()) throw new IllegalStateException("Logto not configured");
|
|
restClient.patch()
|
|
.uri(config.getLogtoEndpoint() + "/api/users/" + userId + "/password")
|
|
.header("Authorization", "Bearer " + getAccessToken())
|
|
.contentType(MediaType.APPLICATION_JSON)
|
|
.body(Map.of("password", newPassword))
|
|
.retrieve()
|
|
.toBodilessEntity();
|
|
}
|
|
|
|
/** Verify a user's current password. Returns true if correct, false if wrong. */
|
|
public boolean verifyUserPassword(String userId, String password) {
|
|
try {
|
|
var token = getAccessToken();
|
|
restClient.post()
|
|
.uri(config.getLogtoEndpoint() + "/api/users/" + userId + "/password/verify")
|
|
.header("Authorization", "Bearer " + token)
|
|
.contentType(MediaType.APPLICATION_JSON)
|
|
.body(Map.of("password", password))
|
|
.retrieve()
|
|
.toBodilessEntity();
|
|
return true;
|
|
} catch (org.springframework.web.client.HttpClientErrorException e) {
|
|
if (e.getStatusCode().value() == 422 || e.getStatusCode().value() == 400) {
|
|
return false;
|
|
}
|
|
throw e;
|
|
}
|
|
}
|
|
|
|
// --- MFA Verification Management ---
|
|
|
|
/** List all MFA verifications for a user. Returns a list of MFA factor objects. */
|
|
@SuppressWarnings("unchecked")
|
|
public List<Map<String, Object>> getUserMfaVerifications(String userId) {
|
|
if (!isAvailable()) return List.of();
|
|
try {
|
|
var resp = restClient.get()
|
|
.uri(config.getLogtoEndpoint() + "/api/users/" + userId + "/mfa-verifications")
|
|
.header("Authorization", "Bearer " + getAccessToken())
|
|
.retrieve()
|
|
.body(List.class);
|
|
return resp != null ? resp : List.of();
|
|
} catch (Exception e) {
|
|
log.warn("Failed to get MFA verifications for user {}: {}", userId, e.getMessage());
|
|
return List.of();
|
|
}
|
|
}
|
|
|
|
/** Create a TOTP MFA verification for a user. Returns the secret and QR code. */
|
|
@SuppressWarnings("unchecked")
|
|
public Map<String, Object> createTotpVerification(String userId, String secret) {
|
|
if (!isAvailable()) return Map.of();
|
|
try {
|
|
return (Map<String, Object>) restClient.post()
|
|
.uri(config.getLogtoEndpoint() + "/api/users/" + userId + "/mfa-verifications")
|
|
.header("Authorization", "Bearer " + getAccessToken())
|
|
.contentType(MediaType.APPLICATION_JSON)
|
|
.body(Map.of("type", "Totp", "secret", secret))
|
|
.retrieve()
|
|
.body(Map.class);
|
|
} catch (Exception e) {
|
|
log.warn("Failed to create TOTP verification for user {}: {}", userId, e.getMessage());
|
|
return Map.of();
|
|
}
|
|
}
|
|
|
|
/** Generate backup codes for a user. Returns the list of codes. */
|
|
@SuppressWarnings("unchecked")
|
|
public Map<String, Object> createBackupCodes(String userId) {
|
|
if (!isAvailable()) return Map.of();
|
|
try {
|
|
return (Map<String, Object>) restClient.post()
|
|
.uri(config.getLogtoEndpoint() + "/api/users/" + userId + "/mfa-verifications")
|
|
.header("Authorization", "Bearer " + getAccessToken())
|
|
.contentType(MediaType.APPLICATION_JSON)
|
|
.body(Map.of("type", "BackupCode"))
|
|
.retrieve()
|
|
.body(Map.class);
|
|
} catch (Exception e) {
|
|
log.warn("Failed to create backup codes for user {}: {}", userId, e.getMessage());
|
|
return Map.of();
|
|
}
|
|
}
|
|
|
|
/** Delete a specific MFA verification for a user. */
|
|
public void deleteMfaVerification(String userId, String verificationId) {
|
|
if (!isAvailable()) return;
|
|
try {
|
|
restClient.delete()
|
|
.uri(config.getLogtoEndpoint() + "/api/users/" + userId + "/mfa-verifications/" + verificationId)
|
|
.header("Authorization", "Bearer " + getAccessToken())
|
|
.retrieve()
|
|
.toBodilessEntity();
|
|
} catch (Exception e) {
|
|
log.warn("Failed to delete MFA verification {} for user {}: {}", verificationId, userId, e.getMessage());
|
|
}
|
|
}
|
|
|
|
/** Delete all MFA verifications for a user (used for admin MFA reset). */
|
|
public void deleteAllMfaVerifications(String userId) {
|
|
List<Map<String, Object>> verifications = getUserMfaVerifications(userId);
|
|
for (Map<String, Object> v : verifications) {
|
|
String id = String.valueOf(v.get("id"));
|
|
deleteMfaVerification(userId, id);
|
|
}
|
|
}
|
|
|
|
/** List WebAuthn credentials for a user (filtered from all MFA verifications). */
|
|
@SuppressWarnings("unchecked")
|
|
public List<Map<String, Object>> getWebAuthnCredentials(String userId) {
|
|
var all = getUserMfaVerifications(userId);
|
|
return all.stream()
|
|
.filter(v -> "WebAuthn".equals(String.valueOf(v.get("type"))))
|
|
.toList();
|
|
}
|
|
|
|
/** Rename a WebAuthn credential. Uses PATCH on the MFA verification. */
|
|
public void renameMfaVerification(String userId, String verificationId, String name) {
|
|
if (!isAvailable()) return;
|
|
try {
|
|
restClient.patch()
|
|
.uri(config.getLogtoEndpoint() + "/api/users/" + userId + "/mfa-verifications/" + verificationId)
|
|
.header("Authorization", "Bearer " + getAccessToken())
|
|
.contentType(MediaType.APPLICATION_JSON)
|
|
.body(Map.of("name", name))
|
|
.retrieve()
|
|
.toBodilessEntity();
|
|
} catch (Exception e) {
|
|
log.warn("Failed to rename MFA verification {} for user {}: {}", verificationId, userId, e.getMessage());
|
|
}
|
|
}
|
|
|
|
/** Update user custom data (partial merge). Used for mfa_method_preference. */
|
|
public void updateUserCustomData(String userId, Map<String, Object> customData) {
|
|
if (!isAvailable()) return;
|
|
try {
|
|
restClient.patch()
|
|
.uri(config.getLogtoEndpoint() + "/api/users/" + userId + "/custom-data")
|
|
.header("Authorization", "Bearer " + getAccessToken())
|
|
.contentType(MediaType.APPLICATION_JSON)
|
|
.body(customData)
|
|
.retrieve()
|
|
.toBodilessEntity();
|
|
} catch (Exception e) {
|
|
log.warn("Failed to update custom data for user {}: {}", userId, e.getMessage());
|
|
}
|
|
}
|
|
|
|
/** Update a user's profile fields (e.g. name). */
|
|
public void updateUserProfile(String userId, Map<String, Object> profile) {
|
|
if (!isAvailable()) throw new IllegalStateException("Logto not configured");
|
|
restClient.patch()
|
|
.uri(config.getLogtoEndpoint() + "/api/users/" + userId)
|
|
.header("Authorization", "Bearer " + getAccessToken())
|
|
.contentType(MediaType.APPLICATION_JSON)
|
|
.body(profile)
|
|
.retrieve()
|
|
.toBodilessEntity();
|
|
}
|
|
|
|
/** Get a user by ID. Returns username, primaryEmail, name. */
|
|
@SuppressWarnings("unchecked")
|
|
public Map<String, Object> getUser(String userId) {
|
|
if (!isAvailable() || userId == null) return null;
|
|
try {
|
|
return (Map<String, Object>) restClient.get()
|
|
.uri(config.getLogtoEndpoint() + "/api/users/" + userId)
|
|
.header("Authorization", "Bearer " + getAccessToken())
|
|
.retrieve()
|
|
.body(Map.class);
|
|
} catch (Exception e) {
|
|
log.warn("Failed to get user {}: {}", userId, e.getMessage());
|
|
return null;
|
|
}
|
|
}
|
|
|
|
// --- Global Role Management ---
|
|
|
|
/** List all users assigned to a global role. */
|
|
@SuppressWarnings("unchecked")
|
|
public List<Map<String, Object>> listRoleUsers(String roleId) {
|
|
var token = getAccessToken();
|
|
var response = restClient.get()
|
|
.uri(config.getLogtoEndpoint() + "/api/roles/" + roleId + "/users")
|
|
.header("Authorization", "Bearer " + token)
|
|
.retrieve()
|
|
.body(List.class);
|
|
return response != null ? response : List.of();
|
|
}
|
|
|
|
/** Find a global role by exact name. Returns null if not found. */
|
|
@SuppressWarnings("unchecked")
|
|
public Map<String, Object> getRoleByName(String roleName) {
|
|
var token = getAccessToken();
|
|
var response = restClient.get()
|
|
.uri(config.getLogtoEndpoint() + "/api/roles?search=" +
|
|
java.net.URLEncoder.encode(roleName, java.nio.charset.StandardCharsets.UTF_8) +
|
|
"&page_size=20")
|
|
.header("Authorization", "Bearer " + token)
|
|
.retrieve()
|
|
.body(List.class);
|
|
if (response == null) return null;
|
|
return ((List<Map<String, Object>>) response).stream()
|
|
.filter(r -> roleName.equals(r.get("name")))
|
|
.findFirst()
|
|
.orElse(null);
|
|
}
|
|
|
|
/** Assign a global role to a user. */
|
|
public void assignGlobalRole(String userId, String roleId) {
|
|
var token = getAccessToken();
|
|
restClient.post()
|
|
.uri(config.getLogtoEndpoint() + "/api/roles/" + roleId + "/users")
|
|
.header("Authorization", "Bearer " + token)
|
|
.contentType(MediaType.APPLICATION_JSON)
|
|
.body(Map.of("userIds", List.of(userId)))
|
|
.retrieve()
|
|
.toBodilessEntity();
|
|
}
|
|
|
|
/** Revoke a global role from a user. */
|
|
public void revokeGlobalRole(String userId, String roleId) {
|
|
var token = getAccessToken();
|
|
restClient.delete()
|
|
.uri(config.getLogtoEndpoint() + "/api/roles/" + roleId + "/users/" + userId)
|
|
.header("Authorization", "Bearer " + token)
|
|
.retrieve()
|
|
.toBodilessEntity();
|
|
}
|
|
|
|
private static final String MGMT_API_RESOURCE = "https://default.logto.app/api";
|
|
|
|
private synchronized String getAccessToken() {
|
|
if (cachedToken != null && Instant.now().isBefore(tokenExpiry.minusSeconds(60))) {
|
|
return cachedToken;
|
|
}
|
|
|
|
try {
|
|
var response = restClient.post()
|
|
.uri(config.getLogtoEndpoint() + "/oidc/token")
|
|
.contentType(MediaType.APPLICATION_FORM_URLENCODED)
|
|
.body("grant_type=client_credentials"
|
|
+ "&client_id=" + config.getM2mClientId()
|
|
+ "&client_secret=" + config.getM2mClientSecret()
|
|
+ "&resource=" + MGMT_API_RESOURCE
|
|
+ "&scope=all")
|
|
.retrieve()
|
|
.body(JsonNode.class);
|
|
|
|
cachedToken = response.get("access_token").asText();
|
|
long expiresIn = response.get("expires_in").asLong();
|
|
tokenExpiry = Instant.now().plusSeconds(expiresIn);
|
|
|
|
return cachedToken;
|
|
} catch (Exception e) {
|
|
log.error("Failed to get Logto Management API token", e);
|
|
throw new RuntimeException("Logto authentication failed", e);
|
|
}
|
|
}
|
|
}
|