Merge pull request 'feature/auth-harmonization' (#155) from feature/auth-harmonization into main
All checks were successful
CI / cleanup-branch (push) Has been skipped
CI / build (push) Successful in 3m58s
CI / docker (push) Successful in 37s
CI / deploy (push) Successful in 38s
CI / deploy-feature (push) Has been skipped

Reviewed-on: #155
This commit was merged in pull request #155.
This commit is contained in:
2026-04-26 19:01:00 +02:00
6 changed files with 1302 additions and 0 deletions

View File

@@ -84,6 +84,12 @@ jobs:
- name: Build and Test
run: mvn clean verify -DskipITs -U --batch-mode
- name: Deploy minter to Maven registry
if: github.event_name == 'push'
run: mvn deploy -DskipTests -DskipITs --batch-mode -pl .,cameleer-server-core,cameleer-license-minter
env:
REGISTRY_TOKEN: ${{ secrets.REGISTRY_TOKEN }}
docker:
needs: build
runs-on: ubuntu-latest

View File

@@ -0,0 +1,24 @@
package com.cameleer.server.app.dto;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.NotNull;
@Schema(description = "Authentication capabilities reported to the SPA so it can render the login page deterministically")
public record AuthCapabilitiesResponse(
@Schema(description = "OIDC interactive login capability") Oidc oidc,
@Schema(description = "Local username/password account capability") LocalAccounts localAccounts
) {
@Schema(description = "OIDC interactive login")
public record Oidc(
@Schema(description = "Whether OIDC is configured AND enabled") boolean enabled,
@Schema(description = "Best-effort display label, e.g. \"Logto\", \"Keycloak\", \"Single Sign-On\"") @NotNull String providerName,
@Schema(description = "When true, OIDC is the canonical entry point and the SPA hides the local form unless ?local is set") boolean primary
) {}
@Schema(description = "Local username/password accounts")
public record LocalAccounts(
@Schema(description = "Whether the local form is reachable at all") boolean enabled,
@Schema(description = "When true, the SPA gates the local form behind ?local with an admin-recovery banner") boolean adminRecoveryOnly
) {}
}

View File

@@ -0,0 +1,41 @@
package com.cameleer.server.app.security;
import java.net.URI;
/**
* Pure utility — derives a display label for an OIDC provider from its issuer URI.
* Used by {@link AuthCapabilitiesController} so the SPA can render
* "Sign in with {providerName}" on the login page.
*
* <p>Pattern-match only — never network-discover. If the issuer doesn't match a
* known vendor pattern, we return the generic "Single Sign-On" label rather than
* leaking hostnames into the UI.
*/
public final class OidcProviderNameDeriver {
private static final String GENERIC = "Single Sign-On";
private OidcProviderNameDeriver() {}
public static String deriveName(String issuerUri) {
if (issuerUri == null || issuerUri.isBlank()) {
return GENERIC;
}
String host;
try {
URI uri = URI.create(issuerUri.trim());
host = uri.getHost();
} catch (IllegalArgumentException e) {
return GENERIC;
}
if (host == null || host.isBlank()) {
return GENERIC;
}
String h = host.toLowerCase();
if (h.contains("logto")) return "Logto";
if (h.contains("keycloak")) return "Keycloak";
if (h.endsWith("auth0.com")) return "Auth0";
if (h.endsWith("okta.com") || h.endsWith("oktapreview.com")) return "Okta";
return GENERIC;
}
}

View File

@@ -0,0 +1,53 @@
package com.cameleer.server.app.security;
import org.junit.jupiter.api.Test;
import static org.assertj.core.api.Assertions.assertThat;
/** Unit tests for {@link OidcProviderNameDeriver}. No Spring context. */
class OidcProviderNameDeriverTest {
@Test
void logtoIssuer_returnsLogto() {
assertThat(OidcProviderNameDeriver.deriveName("https://auth.logto.example/")).isEqualTo("Logto");
assertThat(OidcProviderNameDeriver.deriveName("https://logto.cameleer.local")).isEqualTo("Logto");
}
@Test
void keycloakIssuer_returnsKeycloak() {
assertThat(OidcProviderNameDeriver.deriveName("https://keycloak.example/realms/cameleer")).isEqualTo("Keycloak");
}
@Test
void auth0Issuer_returnsAuth0() {
assertThat(OidcProviderNameDeriver.deriveName("https://example.auth0.com/")).isEqualTo("Auth0");
}
@Test
void oktaIssuer_returnsOkta() {
assertThat(OidcProviderNameDeriver.deriveName("https://dev-123.okta.com/")).isEqualTo("Okta");
assertThat(OidcProviderNameDeriver.deriveName("https://login.oktapreview.com/")).isEqualTo("Okta");
}
@Test
void unknownIssuer_returnsGenericLabel() {
assertThat(OidcProviderNameDeriver.deriveName("https://idp.example.com/")).isEqualTo("Single Sign-On");
}
@Test
void blankOrNullIssuer_returnsGenericLabel() {
assertThat(OidcProviderNameDeriver.deriveName("")).isEqualTo("Single Sign-On");
assertThat(OidcProviderNameDeriver.deriveName(null)).isEqualTo("Single Sign-On");
assertThat(OidcProviderNameDeriver.deriveName(" ")).isEqualTo("Single Sign-On");
}
@Test
void malformedUri_returnsGenericLabel() {
assertThat(OidcProviderNameDeriver.deriveName("not a url")).isEqualTo("Single Sign-On");
}
@Test
void caseInsensitiveMatching() {
assertThat(OidcProviderNameDeriver.deriveName("https://AUTH.LOGTO.EXAMPLE/")).isEqualTo("Logto");
}
}

File diff suppressed because it is too large Load Diff

11
pom.xml
View File

@@ -65,6 +65,17 @@
</repository>
</repositories>
<distributionManagement>
<repository>
<id>gitea</id>
<url>https://gitea.siegeln.net/api/packages/cameleer/maven</url>
</repository>
<snapshotRepository>
<id>gitea</id>
<url>https://gitea.siegeln.net/api/packages/cameleer/maven</url>
</snapshotRepository>
</distributionManagement>
<build>
<pluginManagement>
<plugins>