feat: register OIDC redirect URIs for provisioned tenant servers
During tenant provisioning, adds /t/{slug}/oidc/callback to the Logto
Traditional Web App's registered redirect URIs. This enables the
server's OIDC login flow to work when accessed via Traefik routing.
Also reads tradAppId from bootstrap JSON via LogtoConfig.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -28,11 +28,13 @@ public class LogtoConfig {
|
|||||||
@Value("${cameleer.identity.server-endpoint:http://cameleer3-server:8081}")
|
@Value("${cameleer.identity.server-endpoint:http://cameleer3-server:8081}")
|
||||||
private String serverEndpoint;
|
private String serverEndpoint;
|
||||||
|
|
||||||
|
private String tradAppId;
|
||||||
|
|
||||||
@PostConstruct
|
@PostConstruct
|
||||||
public void init() {
|
public void init() {
|
||||||
if (isConfigured()) return;
|
if (isConfigured()) return;
|
||||||
|
|
||||||
// Fall back to bootstrap file for M2M credentials
|
// Fall back to bootstrap file for M2M credentials + trad app ID
|
||||||
try {
|
try {
|
||||||
File file = new File(BOOTSTRAP_FILE);
|
File file = new File(BOOTSTRAP_FILE);
|
||||||
if (file.exists()) {
|
if (file.exists()) {
|
||||||
@@ -43,6 +45,9 @@ public class LogtoConfig {
|
|||||||
if ((m2mClientSecret == null || m2mClientSecret.isEmpty()) && node.has("m2mClientSecret")) {
|
if ((m2mClientSecret == null || m2mClientSecret.isEmpty()) && node.has("m2mClientSecret")) {
|
||||||
m2mClientSecret = node.get("m2mClientSecret").asText();
|
m2mClientSecret = node.get("m2mClientSecret").asText();
|
||||||
}
|
}
|
||||||
|
if (node.has("tradAppId")) {
|
||||||
|
tradAppId = node.get("tradAppId").asText();
|
||||||
|
}
|
||||||
log.info("Loaded M2M credentials from bootstrap file");
|
log.info("Loaded M2M credentials from bootstrap file");
|
||||||
}
|
}
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
@@ -54,6 +59,7 @@ public class LogtoConfig {
|
|||||||
public String getM2mClientId() { return m2mClientId; }
|
public String getM2mClientId() { return m2mClientId; }
|
||||||
public String getM2mClientSecret() { return m2mClientSecret; }
|
public String getM2mClientSecret() { return m2mClientSecret; }
|
||||||
public String getServerEndpoint() { return serverEndpoint; }
|
public String getServerEndpoint() { return serverEndpoint; }
|
||||||
|
public String getTradAppId() { return tradAppId; }
|
||||||
|
|
||||||
public boolean isConfigured() {
|
public boolean isConfigured() {
|
||||||
return logtoEndpoint != null && !logtoEndpoint.isEmpty()
|
return logtoEndpoint != null && !logtoEndpoint.isEmpty()
|
||||||
|
|||||||
@@ -75,6 +75,50 @@ public class LogtoManagementClient {
|
|||||||
.toBodilessEntity();
|
.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) {
|
public List<Map<String, String>> getUserOrganizations(String userId) {
|
||||||
if (!isAvailable()) return List.of();
|
if (!isAvailable()) return List.of();
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,10 @@ package net.siegeln.cameleer.saas.vendor;
|
|||||||
|
|
||||||
import net.siegeln.cameleer.saas.audit.AuditAction;
|
import net.siegeln.cameleer.saas.audit.AuditAction;
|
||||||
import net.siegeln.cameleer.saas.audit.AuditService;
|
import net.siegeln.cameleer.saas.audit.AuditService;
|
||||||
|
import net.siegeln.cameleer.saas.identity.LogtoConfig;
|
||||||
import net.siegeln.cameleer.saas.identity.LogtoManagementClient;
|
import net.siegeln.cameleer.saas.identity.LogtoManagementClient;
|
||||||
import net.siegeln.cameleer.saas.identity.ServerApiClient;
|
import net.siegeln.cameleer.saas.identity.ServerApiClient;
|
||||||
|
import net.siegeln.cameleer.saas.provisioning.ProvisioningProperties;
|
||||||
import net.siegeln.cameleer.saas.identity.ServerApiClient.ServerHealthResponse;
|
import net.siegeln.cameleer.saas.identity.ServerApiClient.ServerHealthResponse;
|
||||||
import net.siegeln.cameleer.saas.license.LicenseEntity;
|
import net.siegeln.cameleer.saas.license.LicenseEntity;
|
||||||
import net.siegeln.cameleer.saas.license.LicenseService;
|
import net.siegeln.cameleer.saas.license.LicenseService;
|
||||||
@@ -38,7 +40,9 @@ public class VendorTenantService {
|
|||||||
private final TenantProvisioner tenantProvisioner;
|
private final TenantProvisioner tenantProvisioner;
|
||||||
private final ServerApiClient serverApiClient;
|
private final ServerApiClient serverApiClient;
|
||||||
private final LogtoManagementClient logtoClient;
|
private final LogtoManagementClient logtoClient;
|
||||||
|
private final LogtoConfig logtoConfig;
|
||||||
private final AuditService auditService;
|
private final AuditService auditService;
|
||||||
|
private final ProvisioningProperties provisioningProps;
|
||||||
|
|
||||||
public VendorTenantService(TenantService tenantService,
|
public VendorTenantService(TenantService tenantService,
|
||||||
TenantRepository tenantRepository,
|
TenantRepository tenantRepository,
|
||||||
@@ -46,14 +50,18 @@ public class VendorTenantService {
|
|||||||
TenantProvisioner tenantProvisioner,
|
TenantProvisioner tenantProvisioner,
|
||||||
ServerApiClient serverApiClient,
|
ServerApiClient serverApiClient,
|
||||||
LogtoManagementClient logtoClient,
|
LogtoManagementClient logtoClient,
|
||||||
AuditService auditService) {
|
LogtoConfig logtoConfig,
|
||||||
|
AuditService auditService,
|
||||||
|
ProvisioningProperties provisioningProps) {
|
||||||
this.tenantService = tenantService;
|
this.tenantService = tenantService;
|
||||||
this.tenantRepository = tenantRepository;
|
this.tenantRepository = tenantRepository;
|
||||||
this.licenseService = licenseService;
|
this.licenseService = licenseService;
|
||||||
this.tenantProvisioner = tenantProvisioner;
|
this.tenantProvisioner = tenantProvisioner;
|
||||||
this.serverApiClient = serverApiClient;
|
this.serverApiClient = serverApiClient;
|
||||||
this.logtoClient = logtoClient;
|
this.logtoClient = logtoClient;
|
||||||
|
this.logtoConfig = logtoConfig;
|
||||||
this.auditService = auditService;
|
this.auditService = auditService;
|
||||||
|
this.provisioningProps = provisioningProps;
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
@@ -86,6 +94,16 @@ public class VendorTenantService {
|
|||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.warn("Failed to add vendor to org for tenant {}: {}", tenant.getSlug(), e.getMessage());
|
log.warn("Failed to add vendor to org for tenant {}: {}", tenant.getSlug(), e.getMessage());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Register OIDC redirect URIs for the tenant's server in the Traditional Web App
|
||||||
|
String tradAppId = logtoConfig.getTradAppId();
|
||||||
|
if (tradAppId != null) {
|
||||||
|
String base = provisioningProps.publicProtocol() + "://" + provisioningProps.publicHost();
|
||||||
|
String slug = tenant.getSlug();
|
||||||
|
logtoClient.addAppRedirectUris(tradAppId,
|
||||||
|
List.of(base + "/t/" + slug + "/oidc/callback"),
|
||||||
|
List.of(base + "/t/" + slug, base + "/t/" + slug + "/login?local"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Generate license
|
// 3. Generate license
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
package net.siegeln.cameleer.saas.vendor;
|
package net.siegeln.cameleer.saas.vendor;
|
||||||
|
|
||||||
import net.siegeln.cameleer.saas.audit.AuditService;
|
import net.siegeln.cameleer.saas.audit.AuditService;
|
||||||
|
import net.siegeln.cameleer.saas.identity.LogtoConfig;
|
||||||
import net.siegeln.cameleer.saas.identity.LogtoManagementClient;
|
import net.siegeln.cameleer.saas.identity.LogtoManagementClient;
|
||||||
import net.siegeln.cameleer.saas.identity.ServerApiClient;
|
import net.siegeln.cameleer.saas.identity.ServerApiClient;
|
||||||
|
import net.siegeln.cameleer.saas.provisioning.ProvisioningProperties;
|
||||||
import net.siegeln.cameleer.saas.license.LicenseEntity;
|
import net.siegeln.cameleer.saas.license.LicenseEntity;
|
||||||
import net.siegeln.cameleer.saas.license.LicenseService;
|
import net.siegeln.cameleer.saas.license.LicenseService;
|
||||||
import net.siegeln.cameleer.saas.provisioning.ProvisionResult;
|
import net.siegeln.cameleer.saas.provisioning.ProvisionResult;
|
||||||
@@ -53,6 +55,9 @@ class VendorTenantServiceTest {
|
|||||||
@Mock
|
@Mock
|
||||||
private LogtoManagementClient logtoClient;
|
private LogtoManagementClient logtoClient;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private LogtoConfig logtoConfig;
|
||||||
|
|
||||||
@Mock
|
@Mock
|
||||||
private AuditService auditService;
|
private AuditService auditService;
|
||||||
|
|
||||||
@@ -60,9 +65,14 @@ class VendorTenantServiceTest {
|
|||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
void setUp() {
|
void setUp() {
|
||||||
|
var provisioningProps = new ProvisioningProperties(
|
||||||
|
"img", "uiimg", "net", "traefik", "localhost", "https",
|
||||||
|
"jdbc:postgresql://pg:5432/db", "https://localhost/oidc",
|
||||||
|
"http://logto:3001/oidc/jwks", "https://localhost");
|
||||||
vendorTenantService = new VendorTenantService(
|
vendorTenantService = new VendorTenantService(
|
||||||
tenantService, tenantRepository, licenseService,
|
tenantService, tenantRepository, licenseService,
|
||||||
tenantProvisioner, serverApiClient, logtoClient, auditService);
|
tenantProvisioner, serverApiClient, logtoClient, logtoConfig,
|
||||||
|
auditService, provisioningProps);
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Helpers ---
|
// --- Helpers ---
|
||||||
|
|||||||
Reference in New Issue
Block a user