feat: register OIDC redirect URIs for provisioned tenant servers
All checks were successful
CI / build (push) Successful in 53s
CI / docker (push) Successful in 34s

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:
hsiegeln
2026-04-10 07:50:38 +02:00
parent 3b345881c6
commit 0a43a7dcd1
4 changed files with 81 additions and 3 deletions

View File

@@ -28,11 +28,13 @@ public class LogtoConfig {
@Value("${cameleer.identity.server-endpoint:http://cameleer3-server:8081}")
private String serverEndpoint;
private String tradAppId;
@PostConstruct
public void init() {
if (isConfigured()) return;
// Fall back to bootstrap file for M2M credentials
// Fall back to bootstrap file for M2M credentials + trad app ID
try {
File file = new File(BOOTSTRAP_FILE);
if (file.exists()) {
@@ -43,6 +45,9 @@ public class LogtoConfig {
if ((m2mClientSecret == null || m2mClientSecret.isEmpty()) && node.has("m2mClientSecret")) {
m2mClientSecret = node.get("m2mClientSecret").asText();
}
if (node.has("tradAppId")) {
tradAppId = node.get("tradAppId").asText();
}
log.info("Loaded M2M credentials from bootstrap file");
}
} catch (Exception e) {
@@ -54,6 +59,7 @@ public class LogtoConfig {
public String getM2mClientId() { return m2mClientId; }
public String getM2mClientSecret() { return m2mClientSecret; }
public String getServerEndpoint() { return serverEndpoint; }
public String getTradAppId() { return tradAppId; }
public boolean isConfigured() {
return logtoEndpoint != null && !logtoEndpoint.isEmpty()

View File

@@ -75,6 +75,50 @@ public class LogtoManagementClient {
.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();

View File

@@ -2,8 +2,10 @@ package net.siegeln.cameleer.saas.vendor;
import net.siegeln.cameleer.saas.audit.AuditAction;
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.ServerApiClient;
import net.siegeln.cameleer.saas.provisioning.ProvisioningProperties;
import net.siegeln.cameleer.saas.identity.ServerApiClient.ServerHealthResponse;
import net.siegeln.cameleer.saas.license.LicenseEntity;
import net.siegeln.cameleer.saas.license.LicenseService;
@@ -38,7 +40,9 @@ public class VendorTenantService {
private final TenantProvisioner tenantProvisioner;
private final ServerApiClient serverApiClient;
private final LogtoManagementClient logtoClient;
private final LogtoConfig logtoConfig;
private final AuditService auditService;
private final ProvisioningProperties provisioningProps;
public VendorTenantService(TenantService tenantService,
TenantRepository tenantRepository,
@@ -46,14 +50,18 @@ public class VendorTenantService {
TenantProvisioner tenantProvisioner,
ServerApiClient serverApiClient,
LogtoManagementClient logtoClient,
AuditService auditService) {
LogtoConfig logtoConfig,
AuditService auditService,
ProvisioningProperties provisioningProps) {
this.tenantService = tenantService;
this.tenantRepository = tenantRepository;
this.licenseService = licenseService;
this.tenantProvisioner = tenantProvisioner;
this.serverApiClient = serverApiClient;
this.logtoClient = logtoClient;
this.logtoConfig = logtoConfig;
this.auditService = auditService;
this.provisioningProps = provisioningProps;
}
@Transactional
@@ -86,6 +94,16 @@ public class VendorTenantService {
} catch (Exception e) {
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

View File

@@ -1,8 +1,10 @@
package net.siegeln.cameleer.saas.vendor;
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.ServerApiClient;
import net.siegeln.cameleer.saas.provisioning.ProvisioningProperties;
import net.siegeln.cameleer.saas.license.LicenseEntity;
import net.siegeln.cameleer.saas.license.LicenseService;
import net.siegeln.cameleer.saas.provisioning.ProvisionResult;
@@ -53,6 +55,9 @@ class VendorTenantServiceTest {
@Mock
private LogtoManagementClient logtoClient;
@Mock
private LogtoConfig logtoConfig;
@Mock
private AuditService auditService;
@@ -60,9 +65,14 @@ class VendorTenantServiceTest {
@BeforeEach
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(
tenantService, tenantRepository, licenseService,
tenantProvisioner, serverApiClient, logtoClient, auditService);
tenantProvisioner, serverApiClient, logtoClient, logtoConfig,
auditService, provisioningProps);
}
// --- Helpers ---