fix(auth): address CAS SSO review — XXE, AccessPolicy, timeouts, release wiring

Blockers:
- Harden XML parsing against XXE (disallow DOCTYPE, external entities/DTDs,
  enable FEATURE_SECURE_PROCESSING) and switch to UTF-8 byte decoding.
- Generalize AccessPolicy.evaluate from OAuthClaims to IdentityClaims; extract
  IdentityAuthenticator so OAuth and CAS share allow/deny/pending evaluation.
  CAS callback now goes through the policy instead of bypassing it with a
  direct bindOrCreate call.
- Configure JDK HttpClient with connect/read timeouts (5s/10s) and disable
  HTTP redirects to prevent ticket exfiltration via a malicious CAS server.

Major:
- Require HTTPS for skillhub.auth.cas.service-url in addition to server-url.
- Stop logging raw service tickets; log claims.subject() instead.
- Remove the dead authCasEnabled web flag — the backend AuthMethodCatalog is
  the single source of truth for CAS visibility, matching how OAuth works.
- Wire SKILLHUB_AUTH_CAS_* env vars into compose.release.yml and add a fully
  documented section in .env.release.example.

Minor:
- CasProtocolVersion enum replaces string comparisons in the validator.
- JSON multi-value array attributes are preserved as List<String> instead of
  silently dropping all but the first element.
- AuthMethod.methodType union adds 'CAS_REDIRECT'.
- application.yml notes that service-url must equal
  ${SKILLHUB_PUBLIC_BASE_URL}/api/v1/auth/cas/callback.

Tests:
- CasTicketValidatorTest tightens URL matching to assert ticket/service/format
  parameters and adds XXE + billion-laughs regression cases.
- IdentityAuthenticatorTest covers ALLOW / PENDING / DENY paths.
- AuthMethodCatalogTest exercises both cas.enabled=true and =false.
- isExternalRedirectMethod predicate extracted and unit-tested.
This commit is contained in:
dongmucat 2026-05-27 15:19:11 +08:00
parent 72ca98552e
commit ace0cb2dd8
28 changed files with 620 additions and 158 deletions

View file

@ -88,6 +88,23 @@ SKILLHUB_AUTH_DIRECT_ENABLED=false
SKILLHUB_WEB_AUTH_DIRECT_ENABLED=false
SKILLHUB_WEB_AUTH_DIRECT_PROVIDER=
# CAS 2.0 / 3.0 SSO. To enable:
# - SKILLHUB_AUTH_CAS_ENABLED=true
# - SKILLHUB_AUTH_CAS_SERVER_URL=https://cas.example.com (CAS server base URL, must be HTTPS)
# - SKILLHUB_AUTH_CAS_SERVICE_URL=https://skillhub.example.com/api/v1/auth/cas/callback
# (must equal ${SKILLHUB_PUBLIC_BASE_URL}/api/v1/auth/cas/callback)
# Once enabled, /api/v1/auth/methods exposes a CAS_REDIRECT entry to the web UI automatically.
SKILLHUB_AUTH_CAS_ENABLED=false
SKILLHUB_AUTH_CAS_SERVER_URL=
SKILLHUB_AUTH_CAS_SERVICE_URL=
SKILLHUB_AUTH_CAS_PROTOCOL_VERSION=3.0
# Override CAS attribute names if your server does not return uid/cn/mail.
SKILLHUB_AUTH_CAS_ATTR_USERNAME=uid
SKILLHUB_AUTH_CAS_ATTR_DISPLAY_NAME=cn
SKILLHUB_AUTH_CAS_ATTR_EMAIL=mail
# Development-only: allow http:// CAS server / service URLs. Never set this in production.
SKILLHUB_AUTH_CAS_ALLOW_INSECURE=false
# SMTP configuration for password reset verification emails.
SPRING_MAIL_HOST=
SPRING_MAIL_PORT=587

View file

@ -73,6 +73,14 @@ services:
SKILLHUB_SECURITY_SCANNER_URL: http://skill-scanner:8000
SKILLHUB_SECURITY_SCANNER_MODE: upload
SKILLHUB_AUTH_DIRECT_ENABLED: ${SKILLHUB_AUTH_DIRECT_ENABLED:-false}
SKILLHUB_AUTH_CAS_ENABLED: ${SKILLHUB_AUTH_CAS_ENABLED:-false}
SKILLHUB_AUTH_CAS_SERVER_URL: ${SKILLHUB_AUTH_CAS_SERVER_URL:-}
SKILLHUB_AUTH_CAS_SERVICE_URL: ${SKILLHUB_AUTH_CAS_SERVICE_URL:-}
SKILLHUB_AUTH_CAS_PROTOCOL_VERSION: ${SKILLHUB_AUTH_CAS_PROTOCOL_VERSION:-3.0}
SKILLHUB_AUTH_CAS_ALLOW_INSECURE: ${SKILLHUB_AUTH_CAS_ALLOW_INSECURE:-false}
SKILLHUB_AUTH_CAS_ATTR_USERNAME: ${SKILLHUB_AUTH_CAS_ATTR_USERNAME:-uid}
SKILLHUB_AUTH_CAS_ATTR_DISPLAY_NAME: ${SKILLHUB_AUTH_CAS_ATTR_DISPLAY_NAME:-cn}
SKILLHUB_AUTH_CAS_ATTR_EMAIL: ${SKILLHUB_AUTH_CAS_ATTR_EMAIL:-mail}
BOOTSTRAP_ADMIN_ENABLED: ${BOOTSTRAP_ADMIN_ENABLED:-false}
BOOTSTRAP_ADMIN_USER_ID: ${BOOTSTRAP_ADMIN_USER_ID:-docker-admin}
BOOTSTRAP_ADMIN_USERNAME: ${BOOTSTRAP_ADMIN_USERNAME:-admin}

View file

@ -106,7 +106,10 @@ skillhub:
email-from-name: ${SKILLHUB_AUTH_PASSWORD_RESET_FROM_NAME:SkillHub}
cas:
enabled: ${SKILLHUB_AUTH_CAS_ENABLED:false}
# Base URL of the CAS server (e.g. https://cas.example.com). Must be HTTPS unless allow-insecure-server=true.
server-url: ${SKILLHUB_AUTH_CAS_SERVER_URL:}
# Must equal ${skillhub.public.base-url}/api/v1/auth/cas/callback — the CAS server validates
# the service ticket against this exact URL, so a mismatch yields INVALID_SERVICE.
service-url: ${SKILLHUB_AUTH_CAS_SERVICE_URL:}
protocol-version: ${SKILLHUB_AUTH_CAS_PROTOCOL_VERSION:3.0}
allow-insecure-server: ${SKILLHUB_AUTH_CAS_ALLOW_INSECURE:false}

View file

@ -125,4 +125,52 @@ class AuthMethodCatalogTest {
"bootstrap-private-sso:private-sso"
);
}
@Test
void listMethodsExposesCasWhenEnabled() {
OAuth2ClientProperties oauthProperties = new OAuth2ClientProperties();
DirectAuthProperties directAuthProperties = new DirectAuthProperties();
AuthSessionBootstrapProperties bootstrapProperties = new AuthSessionBootstrapProperties();
CasProperties casProperties = new CasProperties();
casProperties.setEnabled(true);
casProperties.setServerUrl("https://cas.example.com");
casProperties.setServiceUrl("https://skillhub.example.com/api/v1/auth/cas/callback");
casProperties.setProtocolVersion("3.0");
casProperties.setAllowInsecureServer(true);
casProperties.validate();
AuthMethodCatalog catalog = new AuthMethodCatalog(
oauthProperties,
directAuthProperties,
bootstrapProperties,
casProperties,
List.of(),
List.of()
);
assertThat(catalog.listMethods(null))
.extracting(method -> method.id() + ":" + method.methodType() + ":" + method.actionUrl())
.contains("cas:CAS_REDIRECT:/api/v1/auth/cas/login");
}
@Test
void listMethodsOmitsCasWhenDisabled() {
OAuth2ClientProperties oauthProperties = new OAuth2ClientProperties();
DirectAuthProperties directAuthProperties = new DirectAuthProperties();
AuthSessionBootstrapProperties bootstrapProperties = new AuthSessionBootstrapProperties();
AuthMethodCatalog catalog = new AuthMethodCatalog(
oauthProperties,
directAuthProperties,
bootstrapProperties,
new CasProperties(),
List.of(),
List.of()
);
assertThat(catalog.listMethods(null))
.extracting(method -> method.id())
.doesNotContain("cas");
}
}

View file

@ -1,12 +1,12 @@
package com.iflytek.skillhub.auth.cas;
import com.iflytek.skillhub.auth.identity.IdentityBindingService;
import com.iflytek.skillhub.auth.identity.AccessDeniedByPolicyException;
import com.iflytek.skillhub.auth.identity.IdentityAuthenticator;
import com.iflytek.skillhub.auth.oauth.AccountDisabledException;
import com.iflytek.skillhub.auth.oauth.AccountPendingException;
import com.iflytek.skillhub.auth.oauth.OAuthLoginRedirectSupport;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.auth.session.PlatformSessionService;
import com.iflytek.skillhub.domain.user.UserStatus;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpSession;
import org.slf4j.Logger;
@ -19,6 +19,8 @@ import org.springframework.web.util.UriComponentsBuilder;
/**
* Handles CAS SSO login flow: redirect to CAS server and callback with ticket validation.
* Delegates access-policy evaluation and principal provisioning to {@link IdentityAuthenticator}
* so the same allow/deny/pending decisions apply to OAuth and CAS uniformly.
*/
@Controller
@RequestMapping("/api/v1/auth/cas")
@ -28,24 +30,21 @@ public class CasLoginController {
private final CasProperties casProperties;
private final CasTicketValidator ticketValidator;
private final IdentityBindingService identityBindingService;
private final IdentityAuthenticator identityAuthenticator;
private final PlatformSessionService sessionService;
public CasLoginController(
CasProperties casProperties,
CasTicketValidator ticketValidator,
IdentityBindingService identityBindingService,
IdentityAuthenticator identityAuthenticator,
PlatformSessionService sessionService
) {
this.casProperties = casProperties;
this.ticketValidator = ticketValidator;
this.identityBindingService = identityBindingService;
this.identityAuthenticator = identityAuthenticator;
this.sessionService = sessionService;
}
/**
* Initiates CAS login by redirecting to the CAS server.
*/
@GetMapping("/login")
public String login(
@RequestParam(required = false) String returnTo,
@ -71,9 +70,6 @@ public class CasLoginController {
return "redirect:" + casLoginUrl;
}
/**
* Handles CAS callback with ticket validation and session establishment.
*/
@GetMapping("/callback")
public String callback(
@RequestParam(required = false) String ticket,
@ -89,11 +85,18 @@ public class CasLoginController {
return "redirect:/login?error=missing_ticket";
}
CasIdentityClaims claims;
try {
CasIdentityClaims claims = ticketValidator.validate(ticket);
log.info("CAS ticket validated successfully for user: {}", claims.subject());
claims = ticketValidator.validate(ticket);
} catch (CasValidationException e) {
log.error("CAS ticket validation failed: {}", e.getMessage());
return "redirect:/login?error=cas_validation_failed";
}
PlatformPrincipal principal = identityBindingService.bindOrCreate(claims, UserStatus.ACTIVE);
log.info("CAS ticket validated for subject={}", claims.subject());
try {
PlatformPrincipal principal = identityAuthenticator.authenticate(claims);
sessionService.establishSession(principal, request);
HttpSession session = request.getSession(false);
@ -103,21 +106,24 @@ public class CasLoginController {
session.removeAttribute(OAuthLoginRedirectSupport.SESSION_RETURN_TO_ATTRIBUTE);
}
String targetUrl = returnTo != null ? returnTo : OAuthLoginRedirectSupport.DEFAULT_TARGET_URL;
String targetUrl = OAuthLoginRedirectSupport.sanitizeReturnTo(returnTo);
if (targetUrl == null) {
targetUrl = OAuthLoginRedirectSupport.DEFAULT_TARGET_URL;
}
log.debug("CAS login successful, redirecting to: {}", targetUrl);
return "redirect:" + targetUrl;
} catch (AccountPendingException e) {
log.warn("CAS user account pending approval: {}", ticket);
log.warn("CAS user pending approval: subject={}", claims.subject());
return "redirect:/pending-approval";
} catch (AccountDisabledException e) {
log.warn("CAS user account disabled: {}", ticket);
log.warn("CAS user disabled: subject={}", claims.subject());
return "redirect:/access-denied";
} catch (AccessDeniedByPolicyException e) {
log.warn("CAS user denied by policy: subject={}", claims.subject());
return "redirect:/access-denied";
} catch (CasValidationException e) {
log.error("CAS ticket validation failed: {}", e.getMessage());
return "redirect:/login?error=cas_validation_failed";
} catch (Exception e) {
log.error("Unexpected error during CAS callback", e);
log.error("Unexpected error during CAS callback for subject={}", claims.subject(), e);
return "redirect:/login?error=internal_error";
}
}

View file

@ -20,6 +20,7 @@ public class CasProperties {
private String protocolVersion = "3.0";
private boolean allowInsecureServer = false;
private Map<String, String> attributes = new HashMap<>();
private CasProtocolVersion resolvedProtocolVersion = CasProtocolVersion.V3_0;
@PostConstruct
public void validate() {
@ -42,8 +43,19 @@ public class CasProperties {
);
}
if (!"2.0".equals(protocolVersion) && !"3.0".equals(protocolVersion)) {
throw new IllegalStateException("skillhub.auth.cas.protocol-version must be either '2.0' or '3.0'");
if (!allowInsecureServer && !serviceUrl.startsWith("https://")) {
throw new IllegalStateException(
"CAS service URL must use HTTPS in production (otherwise the service ticket " +
"is transmitted in plaintext). " +
"Set skillhub.auth.cas.allow-insecure-server=true to override for development."
);
}
try {
this.resolvedProtocolVersion = CasProtocolVersion.from(protocolVersion);
} catch (IllegalArgumentException e) {
throw new IllegalStateException(
"skillhub.auth.cas.protocol-version must be either '2.0' or '3.0'", e);
}
if (attributes.get("username") == null || attributes.get("username").isBlank()) {
@ -57,6 +69,14 @@ public class CasProperties {
}
}
/**
* Resolved protocol version after validation. Use this in the call path instead of
* {@link #getProtocolVersion()} string comparisons.
*/
public CasProtocolVersion resolvedProtocolVersion() {
return resolvedProtocolVersion;
}
public boolean isEnabled() {
return enabled;
}

View file

@ -0,0 +1,46 @@
package com.iflytek.skillhub.auth.cas;
/**
* CAS protocol version: determines the validation endpoint and response format.
*/
public enum CasProtocolVersion {
/** CAS 2.0: /serviceValidate, XML response. */
V2_0("2.0", "/serviceValidate", false),
/** CAS 3.0: /p3/serviceValidate, JSON response (with format=JSON). */
V3_0("3.0", "/p3/serviceValidate", true);
private final String wireValue;
private final String validatePath;
private final boolean json;
CasProtocolVersion(String wireValue, String validatePath, boolean json) {
this.wireValue = wireValue;
this.validatePath = validatePath;
this.json = json;
}
public String wireValue() {
return wireValue;
}
public String validatePath() {
return validatePath;
}
public boolean isJson() {
return json;
}
public static CasProtocolVersion from(String value) {
if (value == null) {
return V3_0;
}
return switch (value.trim()) {
case "2.0" -> V2_0;
case "3.0" -> V3_0;
default -> throw new IllegalArgumentException(
"Unsupported CAS protocol version: " + value + " (expected '2.0' or '3.0')"
);
};
}
}

View file

@ -5,6 +5,7 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.client.JdkClientHttpRequestFactory;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import org.springframework.web.util.UriComponentsBuilder;
@ -15,6 +16,8 @@ import org.w3c.dom.NodeList;
import javax.xml.parsers.DocumentBuilder;
import javax.xml.parsers.DocumentBuilderFactory;
import java.io.ByteArrayInputStream;
import java.net.http.HttpClient;
import java.time.Duration;
import java.util.HashMap;
import java.util.Map;
@ -26,6 +29,8 @@ import java.util.Map;
public class CasTicketValidator {
private static final Logger log = LoggerFactory.getLogger(CasTicketValidator.class);
private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(5);
private static final Duration READ_TIMEOUT = Duration.ofSeconds(10);
private final CasProperties casProperties;
private final RestClient restClient;
@ -33,7 +38,7 @@ public class CasTicketValidator {
@Autowired
public CasTicketValidator(CasProperties casProperties, ObjectMapper objectMapper) {
this(casProperties, objectMapper, RestClient.builder().build());
this(casProperties, objectMapper, defaultRestClient());
}
CasTicketValidator(CasProperties casProperties, ObjectMapper objectMapper, RestClient restClient) {
@ -42,6 +47,16 @@ public class CasTicketValidator {
this.restClient = restClient;
}
private static RestClient defaultRestClient() {
HttpClient httpClient = HttpClient.newBuilder()
.connectTimeout(CONNECT_TIMEOUT)
.followRedirects(HttpClient.Redirect.NEVER)
.build();
JdkClientHttpRequestFactory factory = new JdkClientHttpRequestFactory(httpClient);
factory.setReadTimeout(READ_TIMEOUT);
return RestClient.builder().requestFactory(factory).build();
}
/**
* Validates a CAS ticket and returns the user attributes.
*
@ -67,7 +82,7 @@ public class CasTicketValidator {
throw new CasValidationException("Empty response from CAS server");
}
if ("3.0".equals(casProperties.getProtocolVersion())) {
if (casProperties.resolvedProtocolVersion().isJson()) {
return parseJsonResponse(response);
} else {
return parseXmlResponse(response);
@ -81,16 +96,14 @@ public class CasTicketValidator {
}
private String buildValidationUrl(String ticket) {
String endpoint = "3.0".equals(casProperties.getProtocolVersion())
? "/p3/serviceValidate"
: "/serviceValidate";
CasProtocolVersion version = casProperties.resolvedProtocolVersion();
UriComponentsBuilder builder = UriComponentsBuilder
.fromHttpUrl(casProperties.getServerUrl() + endpoint)
.fromHttpUrl(casProperties.getServerUrl() + version.validatePath())
.queryParam("ticket", ticket)
.queryParam("service", casProperties.getServiceUrl());
if ("3.0".equals(casProperties.getProtocolVersion())) {
if (version.isJson()) {
builder.queryParam("format", "JSON");
}
@ -124,9 +137,19 @@ public class CasTicketValidator {
JsonNode value = entry.getValue();
if (value.isTextual()) {
attributes.put(entry.getKey(), value.asText());
} else if (value.isArray() && value.size() > 0) {
attributes.put(entry.getKey(), value.get(0).asText());
} else {
} else if (value.isArray()) {
if (value.size() == 1) {
attributes.put(entry.getKey(), value.get(0).asText());
} else if (value.size() > 1) {
java.util.List<String> values = new java.util.ArrayList<>(value.size());
for (JsonNode item : value) {
values.add(item.asText());
}
attributes.put(entry.getKey(), java.util.List.copyOf(values));
}
} else if (value.isNumber() || value.isBoolean()) {
attributes.put(entry.getKey(), value.asText());
} else if (!value.isNull()) {
attributes.put(entry.getKey(), value.toString());
}
});
@ -137,9 +160,16 @@ public class CasTicketValidator {
private CasIdentityClaims parseXmlResponse(String response) throws Exception {
DocumentBuilderFactory factory = DocumentBuilderFactory.newInstance();
factory.setFeature(javax.xml.XMLConstants.FEATURE_SECURE_PROCESSING, true);
factory.setFeature("http://apache.org/xml/features/disallow-doctype-decl", true);
factory.setFeature("http://xml.org/sax/features/external-general-entities", false);
factory.setFeature("http://xml.org/sax/features/external-parameter-entities", false);
factory.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false);
factory.setXIncludeAware(false);
factory.setExpandEntityReferences(false);
factory.setNamespaceAware(true);
DocumentBuilder builder = factory.newDocumentBuilder();
Document doc = builder.parse(new ByteArrayInputStream(response.getBytes()));
Document doc = builder.parse(new ByteArrayInputStream(response.getBytes(java.nio.charset.StandardCharsets.UTF_8)));
Element root = doc.getDocumentElement();
@ -192,10 +222,20 @@ public class CasTicketValidator {
String displayNameAttr = casProperties.getAttributes().get("display-name");
String emailAttr = casProperties.getAttributes().get("email");
String subject = attributes.getOrDefault(usernameAttr, user).toString();
String displayName = attributes.getOrDefault(displayNameAttr, user).toString();
String email = attributes.containsKey(emailAttr) ? attributes.get(emailAttr).toString() : null;
String subject = firstStringOr(attributes.get(usernameAttr), user);
String displayName = firstStringOr(attributes.get(displayNameAttr), user);
String email = firstStringOr(attributes.get(emailAttr), null);
return new CasIdentityClaims(subject, email, displayName, attributes);
}
private static String firstStringOr(Object value, String fallback) {
if (value == null) {
return fallback;
}
if (value instanceof java.util.List<?> list) {
return list.isEmpty() ? fallback : String.valueOf(list.get(0));
}
return value.toString();
}
}

View file

@ -0,0 +1,10 @@
package com.iflytek.skillhub.auth.identity;
/**
* Thrown when an authenticated upstream identity is rejected by the configured AccessPolicy.
*/
public class AccessDeniedByPolicyException extends RuntimeException {
public AccessDeniedByPolicyException() {
super("Access denied by policy");
}
}

View file

@ -0,0 +1,46 @@
package com.iflytek.skillhub.auth.identity;
import com.iflytek.skillhub.auth.oauth.AccountPendingException;
import com.iflytek.skillhub.auth.policy.AccessDecision;
import com.iflytek.skillhub.auth.policy.AccessPolicy;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.domain.user.UserStatus;
import org.springframework.stereotype.Service;
/**
* Provider-neutral identity authentication: evaluates access policy and creates or binds a
* platform principal. Used by all upstream identity flows (OAuth, CAS, etc.) so that the same
* allow/deny/pending decisions apply regardless of protocol.
*/
@Service
public class IdentityAuthenticator {
private final AccessPolicy accessPolicy;
private final IdentityBindingService identityBindingService;
public IdentityAuthenticator(AccessPolicy accessPolicy, IdentityBindingService identityBindingService) {
this.accessPolicy = accessPolicy;
this.identityBindingService = identityBindingService;
}
/**
* Evaluates policy and returns a platform principal for an allowed identity.
*
* @throws AccountPendingException if the policy yields PENDING_APPROVAL
* @throws AccessDeniedByPolicyException if the policy yields DENY
* @throws com.iflytek.skillhub.auth.oauth.AccountDisabledException if the user is disabled
*/
public PlatformPrincipal authenticate(IdentityClaims claims) {
AccessDecision decision = accessPolicy.evaluate(claims);
if (decision == AccessDecision.PENDING_APPROVAL) {
identityBindingService.createPendingUserIfAbsent(claims);
throw new AccountPendingException();
}
if (decision == AccessDecision.DENY) {
throw new AccessDeniedByPolicyException();
}
return identityBindingService.bindOrCreate(claims, UserStatus.ACTIVE);
}
}

View file

@ -1,10 +1,9 @@
package com.iflytek.skillhub.auth.oauth;
import com.iflytek.skillhub.auth.identity.IdentityBindingService;
import com.iflytek.skillhub.auth.policy.AccessDecision;
import com.iflytek.skillhub.auth.policy.AccessPolicy;
import com.iflytek.skillhub.auth.identity.AccessDeniedByPolicyException;
import com.iflytek.skillhub.auth.identity.IdentityAuthenticator;
import com.iflytek.skillhub.auth.identity.IdentityClaims;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.domain.user.UserStatus;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpSession;
import java.net.URLEncoder;
@ -31,16 +30,13 @@ public class OAuthLoginFlowService {
private final DefaultOAuth2UserService delegate = new DefaultOAuth2UserService();
private final Map<String, OAuthClaimsExtractor> extractors;
private final AccessPolicy accessPolicy;
private final IdentityBindingService identityBindingService;
private final IdentityAuthenticator identityAuthenticator;
public OAuthLoginFlowService(List<OAuthClaimsExtractor> extractorList,
AccessPolicy accessPolicy,
IdentityBindingService identityBindingService) {
IdentityAuthenticator identityAuthenticator) {
this.extractors = extractorList.stream()
.collect(Collectors.toMap(OAuthClaimsExtractor::getProvider, Function.identity()));
this.accessPolicy = accessPolicy;
this.identityBindingService = identityBindingService;
this.identityAuthenticator = identityAuthenticator;
}
public AuthenticatedLoginContext loadLoginContext(OAuth2UserRequest request) {
@ -59,20 +55,14 @@ public class OAuthLoginFlowService {
return new AuthenticatedLoginContext(upstreamUser, principal);
}
public PlatformPrincipal authenticate(OAuthClaims claims) {
AccessDecision decision = accessPolicy.evaluate(claims);
if (decision == AccessDecision.PENDING_APPROVAL) {
identityBindingService.createPendingUserIfAbsent(claims);
throw new AccountPendingException();
}
if (decision == AccessDecision.DENY) {
public PlatformPrincipal authenticate(IdentityClaims claims) {
try {
return identityAuthenticator.authenticate(claims);
} catch (AccessDeniedByPolicyException e) {
throw new OAuth2AuthenticationException(
new OAuth2Error("access_denied", "Access denied by policy", null)
);
}
return identityBindingService.bindOrCreate(claims, UserStatus.ACTIVE);
}
public void rememberReturnTo(HttpServletRequest request) {

View file

@ -1,10 +1,10 @@
package com.iflytek.skillhub.auth.policy;
import com.iflytek.skillhub.auth.oauth.OAuthClaims;
import com.iflytek.skillhub.auth.identity.IdentityClaims;
/**
* Policy contract for deciding whether externally authenticated users may enter the platform.
*/
public interface AccessPolicy {
AccessDecision evaluate(OAuthClaims claims);
AccessDecision evaluate(IdentityClaims claims);
}

View file

@ -1,10 +1,10 @@
package com.iflytek.skillhub.auth.policy;
import com.iflytek.skillhub.auth.oauth.OAuthClaims;
import com.iflytek.skillhub.auth.identity.IdentityClaims;
import java.util.Set;
/**
* Access policy that allows login only when the OAuth email belongs to an approved domain.
* Access policy that allows login only when the upstream email belongs to an approved domain.
*/
public class EmailDomainAccessPolicy implements AccessPolicy {
private final Set<String> allowedDomains;
@ -14,7 +14,7 @@ public class EmailDomainAccessPolicy implements AccessPolicy {
}
@Override
public AccessDecision evaluate(OAuthClaims claims) {
public AccessDecision evaluate(IdentityClaims claims) {
if (claims.email() == null) return AccessDecision.DENY;
String domain = claims.email().substring(claims.email().indexOf('@') + 1);
return allowedDomains.contains(domain.toLowerCase())

View file

@ -1,13 +1,13 @@
package com.iflytek.skillhub.auth.policy;
import com.iflytek.skillhub.auth.oauth.OAuthClaims;
import com.iflytek.skillhub.auth.identity.IdentityClaims;
/**
* Access policy that accepts all OAuth-authenticated users.
* Access policy that accepts all externally authenticated users.
*/
public class OpenAccessPolicy implements AccessPolicy {
@Override
public AccessDecision evaluate(OAuthClaims claims) {
public AccessDecision evaluate(IdentityClaims claims) {
return AccessDecision.ALLOW;
}
}

View file

@ -1,10 +1,10 @@
package com.iflytek.skillhub.auth.policy;
import com.iflytek.skillhub.auth.oauth.OAuthClaims;
import com.iflytek.skillhub.auth.identity.IdentityClaims;
import java.util.Set;
/**
* Access policy that limits login to explicitly allowed OAuth providers.
* Access policy that limits login to explicitly allowed identity providers.
*/
public class ProviderAllowlistAccessPolicy implements AccessPolicy {
private final Set<String> allowedProviders;
@ -14,7 +14,7 @@ public class ProviderAllowlistAccessPolicy implements AccessPolicy {
}
@Override
public AccessDecision evaluate(OAuthClaims claims) {
public AccessDecision evaluate(IdentityClaims claims) {
return allowedProviders.contains(claims.provider())
? AccessDecision.ALLOW : AccessDecision.DENY;
}

View file

@ -1,6 +1,6 @@
package com.iflytek.skillhub.auth.policy;
import com.iflytek.skillhub.auth.oauth.OAuthClaims;
import com.iflytek.skillhub.auth.identity.IdentityClaims;
import java.util.Set;
/**
@ -14,7 +14,7 @@ public class SubjectWhitelistAccessPolicy implements AccessPolicy {
}
@Override
public AccessDecision evaluate(OAuthClaims claims) {
public AccessDecision evaluate(IdentityClaims claims) {
String key = claims.provider() + ":" + claims.subject();
return whitelistedSubjects.contains(key)
? AccessDecision.ALLOW : AccessDecision.DENY;

View file

@ -7,12 +7,12 @@ import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import com.iflytek.skillhub.auth.identity.IdentityBindingService;
import com.iflytek.skillhub.auth.identity.AccessDeniedByPolicyException;
import com.iflytek.skillhub.auth.identity.IdentityAuthenticator;
import com.iflytek.skillhub.auth.oauth.AccountDisabledException;
import com.iflytek.skillhub.auth.oauth.AccountPendingException;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.auth.session.PlatformSessionService;
import com.iflytek.skillhub.domain.user.UserStatus;
import java.util.Map;
import java.util.Set;
import org.junit.jupiter.api.BeforeEach;
@ -30,7 +30,7 @@ class CasLoginControllerTest {
private CasTicketValidator ticketValidator;
@Mock
private IdentityBindingService identityBindingService;
private IdentityAuthenticator identityAuthenticator;
@Mock
private PlatformSessionService sessionService;
@ -47,7 +47,7 @@ class CasLoginControllerTest {
casProperties.setProtocolVersion("3.0");
casProperties.setAllowInsecureServer(true);
controller = new CasLoginController(casProperties, ticketValidator, identityBindingService, sessionService);
controller = new CasLoginController(casProperties, ticketValidator, identityAuthenticator, sessionService);
}
@Test
@ -99,7 +99,7 @@ class CasLoginControllerTest {
PlatformPrincipal principal = new PlatformPrincipal("usr_123", "Zhang San", "zhangsan@example.com", null, "cas", Set.of("USER"));
when(ticketValidator.validate("ST-12345")).thenReturn(claims);
when(identityBindingService.bindOrCreate(claims, UserStatus.ACTIVE)).thenReturn(principal);
when(identityAuthenticator.authenticate(claims)).thenReturn(principal);
String result = controller.callback("ST-12345", request);
@ -115,13 +115,31 @@ class CasLoginControllerTest {
PlatformPrincipal principal = new PlatformPrincipal("usr_456", "User One", null, null, "cas", Set.of("USER"));
when(ticketValidator.validate("ST-99999")).thenReturn(claims);
when(identityBindingService.bindOrCreate(claims, UserStatus.ACTIVE)).thenReturn(principal);
when(identityAuthenticator.authenticate(claims)).thenReturn(principal);
String result = controller.callback("ST-99999", request);
assertThat(result).isEqualTo("redirect:/dashboard");
}
@Test
void callback_sanitizesUnsafeReturnTo() {
MockHttpServletRequest request = new MockHttpServletRequest();
MockHttpSession session = new MockHttpSession();
session.setAttribute("skillhub.oauth.returnTo", "https://evil.example/steal");
request.setSession(session);
CasIdentityClaims claims = new CasIdentityClaims("u", null, "U", Map.of());
PlatformPrincipal principal = new PlatformPrincipal("usr_x", "U", null, null, "cas", Set.of("USER"));
when(ticketValidator.validate("ST-evil")).thenReturn(claims);
when(identityAuthenticator.authenticate(claims)).thenReturn(principal);
String result = controller.callback("ST-evil", request);
assertThat(result).isEqualTo("redirect:/dashboard");
}
@Test
void callback_missingTicket_redirectsWithError() {
MockHttpServletRequest request = new MockHttpServletRequest();
@ -157,7 +175,7 @@ class CasLoginControllerTest {
CasIdentityClaims claims = new CasIdentityClaims("pending-user", null, "Pending", Map.of());
when(ticketValidator.validate("ST-pending")).thenReturn(claims);
when(identityBindingService.bindOrCreate(claims, UserStatus.ACTIVE)).thenThrow(new AccountPendingException());
when(identityAuthenticator.authenticate(claims)).thenThrow(new AccountPendingException());
String result = controller.callback("ST-pending", request);
@ -170,13 +188,26 @@ class CasLoginControllerTest {
CasIdentityClaims claims = new CasIdentityClaims("disabled-user", null, "Disabled", Map.of());
when(ticketValidator.validate("ST-disabled")).thenReturn(claims);
when(identityBindingService.bindOrCreate(claims, UserStatus.ACTIVE)).thenThrow(new AccountDisabledException());
when(identityAuthenticator.authenticate(claims)).thenThrow(new AccountDisabledException());
String result = controller.callback("ST-disabled", request);
assertThat(result).isEqualTo("redirect:/access-denied");
}
@Test
void callback_accessPolicyDeny_redirectsToAccessDenied() {
MockHttpServletRequest request = new MockHttpServletRequest();
CasIdentityClaims claims = new CasIdentityClaims("denied-user", "denied@bad.example", "Denied", Map.of());
when(ticketValidator.validate("ST-denied")).thenReturn(claims);
when(identityAuthenticator.authenticate(claims)).thenThrow(new AccessDeniedByPolicyException());
String result = controller.callback("ST-denied", request);
assertThat(result).isEqualTo("redirect:/access-denied");
}
@Test
void callback_validationFailed_redirectsWithError() {
MockHttpServletRequest request = new MockHttpServletRequest();

View file

@ -2,11 +2,16 @@ package com.iflytek.skillhub.auth.cas;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.hamcrest.Matchers.allOf;
import static org.hamcrest.Matchers.containsString;
import static org.hamcrest.Matchers.not;
import static org.hamcrest.Matchers.startsWith;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestTo;
import static org.springframework.test.web.client.response.MockRestResponseCreators.withSuccess;
import com.fasterxml.jackson.databind.ObjectMapper;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@ -14,9 +19,6 @@ import org.springframework.http.MediaType;
import org.springframework.test.web.client.MockRestServiceServer;
import org.springframework.web.client.RestClient;
import static org.springframework.test.web.client.match.MockRestRequestMatchers.requestToUriTemplate;
import static org.hamcrest.Matchers.containsString;
class CasTicketValidatorTest {
private MockRestServiceServer mockServer;
@ -25,28 +27,37 @@ class CasTicketValidatorTest {
@BeforeEach
void setUp() {
casProperties = new CasProperties();
casProperties.setEnabled(true);
casProperties.setServerUrl("https://cas.example.com");
casProperties.setServiceUrl("https://skillhub.example.com/api/v1/auth/cas/callback");
casProperties.setProtocolVersion("3.0");
casProperties.setAllowInsecureServer(true);
casProperties = newProperties("3.0");
validator = newValidator(casProperties);
}
private CasProperties newProperties(String protocolVersion) {
CasProperties props = new CasProperties();
props.setEnabled(true);
props.setServerUrl("https://cas.example.com");
props.setServiceUrl("https://skillhub.example.com/api/v1/auth/cas/callback");
props.setProtocolVersion(protocolVersion);
props.setAllowInsecureServer(true);
Map<String, String> attributes = new HashMap<>();
attributes.put("username", "uid");
attributes.put("display-name", "cn");
attributes.put("email", "mail");
casProperties.setAttributes(attributes);
props.setAttributes(attributes);
// Trigger @PostConstruct logic (resolvedProtocolVersion etc.)
props.validate();
return props;
}
private CasTicketValidator newValidator(CasProperties props) {
RestClient.Builder builder = RestClient.builder();
mockServer = MockRestServiceServer.bindTo(builder).build();
RestClient restClient = builder.build();
validator = new CasTicketValidator(casProperties, new ObjectMapper(), restClient);
return new CasTicketValidator(props, new ObjectMapper(), builder.build());
}
@Test
void validate_cas30_json_success() {
void validate_cas30_json_success_buildsExactValidationUrl() {
String jsonResponse = """
{
"serviceResponse": {
@ -63,8 +74,12 @@ class CasTicketValidatorTest {
}
""";
mockServer.expect(requestTo(containsString("/p3/serviceValidate")))
.andRespond(withSuccess(jsonResponse, MediaType.APPLICATION_JSON));
mockServer.expect(requestTo(allOf(
startsWith("https://cas.example.com/p3/serviceValidate"),
containsString("ticket=ST-12345"),
containsString("service=https"),
containsString("format=JSON")
))).andRespond(withSuccess(jsonResponse, MediaType.APPLICATION_JSON));
CasIdentityClaims claims = validator.validate("ST-12345");
@ -101,11 +116,9 @@ class CasTicketValidatorTest {
}
@Test
void validate_cas20_xml_success() {
casProperties.setProtocolVersion("2.0");
RestClient.Builder builder = RestClient.builder();
mockServer = MockRestServiceServer.bindTo(builder).build();
validator = new CasTicketValidator(casProperties, new ObjectMapper(), builder.build());
void validate_cas20_xml_success_buildsExactValidationUrl() {
casProperties = newProperties("2.0");
validator = newValidator(casProperties);
String xmlResponse = """
<cas:serviceResponse xmlns:cas="http://www.yale.edu/tp/cas">
@ -120,8 +133,13 @@ class CasTicketValidatorTest {
</cas:serviceResponse>
""";
mockServer.expect(requestTo(containsString("/serviceValidate")))
.andRespond(withSuccess(xmlResponse, MediaType.APPLICATION_XML));
mockServer.expect(requestTo(allOf(
startsWith("https://cas.example.com/serviceValidate"),
not(containsString("/p3/")),
containsString("ticket=ST-67890"),
containsString("service=https"),
not(containsString("format=JSON"))
))).andRespond(withSuccess(xmlResponse, MediaType.APPLICATION_XML));
CasIdentityClaims claims = validator.validate("ST-67890");
@ -135,10 +153,8 @@ class CasTicketValidatorTest {
@Test
void validate_cas20_xml_authenticationFailure() {
casProperties.setProtocolVersion("2.0");
RestClient.Builder builder = RestClient.builder();
mockServer = MockRestServiceServer.bindTo(builder).build();
validator = new CasTicketValidator(casProperties, new ObjectMapper(), builder.build());
casProperties = newProperties("2.0");
validator = newValidator(casProperties);
String xmlResponse = """
<cas:serviceResponse xmlns:cas="http://www.yale.edu/tp/cas">
@ -192,7 +208,7 @@ class CasTicketValidatorTest {
}
@Test
void validate_cas30_json_arrayAttributes() {
void validate_cas30_json_singleArrayAttribute_unwrapped() {
String jsonResponse = """
{
"serviceResponse": {
@ -201,8 +217,7 @@ class CasTicketValidatorTest {
"attributes": {
"uid": ["wangwu"],
"cn": ["Wang Wu"],
"mail": ["wangwu@example.com"],
"memberOf": ["group1", "group2"]
"mail": ["wangwu@example.com"]
}
}
}
@ -212,7 +227,7 @@ class CasTicketValidatorTest {
mockServer.expect(requestTo(containsString("/p3/serviceValidate")))
.andRespond(withSuccess(jsonResponse, MediaType.APPLICATION_JSON));
CasIdentityClaims claims = validator.validate("ST-array");
CasIdentityClaims claims = validator.validate("ST-array1");
assertThat(claims.subject()).isEqualTo("wangwu");
assertThat(claims.providerLogin()).isEqualTo("Wang Wu");
@ -221,6 +236,37 @@ class CasTicketValidatorTest {
mockServer.verify();
}
@Test
void validate_cas30_json_multiValueArray_preservedAsList() {
String jsonResponse = """
{
"serviceResponse": {
"authenticationSuccess": {
"user": "wangwu",
"attributes": {
"uid": "wangwu",
"cn": "Wang Wu",
"memberOf": ["group1", "group2", "group3"]
}
}
}
}
""";
mockServer.expect(requestTo(containsString("/p3/serviceValidate")))
.andRespond(withSuccess(jsonResponse, MediaType.APPLICATION_JSON));
CasIdentityClaims claims = validator.validate("ST-multi");
assertThat(claims.extra()).containsKey("memberOf");
assertThat(claims.extra().get("memberOf")).isInstanceOf(List.class);
@SuppressWarnings("unchecked")
List<String> memberOf = (List<String>) claims.extra().get("memberOf");
assertThat(memberOf).containsExactly("group1", "group2", "group3");
mockServer.verify();
}
@Test
void validate_fallsBackToUserWhenAttributesMissing() {
String jsonResponse = """
@ -245,4 +291,69 @@ class CasTicketValidatorTest {
mockServer.verify();
}
@Test
void validate_cas20_xml_xxePayload_isRejected() {
casProperties = newProperties("2.0");
validator = newValidator(casProperties);
// XXE attempt: external entity referencing a local file. With XXE hardening enabled, the
// parser must refuse the DOCTYPE outright (or refuse to resolve the entity); either way
// the local file content must NOT appear in the parsed user attribute.
String xxePayload = """
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE serviceResponse [
<!ENTITY xxe SYSTEM "file:///etc/passwd">
]>
<cas:serviceResponse xmlns:cas="http://www.yale.edu/tp/cas">
<cas:authenticationSuccess>
<cas:user>&xxe;</cas:user>
<cas:attributes>
<cas:uid>&xxe;</cas:uid>
</cas:attributes>
</cas:authenticationSuccess>
</cas:serviceResponse>
""";
mockServer.expect(requestTo(containsString("/serviceValidate")))
.andRespond(withSuccess(xxePayload, MediaType.APPLICATION_XML));
assertThatThrownBy(() -> validator.validate("ST-xxe"))
.isInstanceOfAny(CasValidationException.class)
.satisfies(e -> {
// The error must NOT contain the local file content; it should signal a parse
// refusal or a missing-user condition (the entity could not be expanded).
String msg = e.getMessage() == null ? "" : e.getMessage();
assertThat(msg).doesNotContain("root:");
assertThat(msg).doesNotContain("/bin/bash");
});
}
@Test
void validate_cas20_xml_billionLaughs_isRejected() {
casProperties = newProperties("2.0");
validator = newValidator(casProperties);
String billionLaughs = """
<?xml version="1.0"?>
<!DOCTYPE lolz [
<!ENTITY lol "lol">
<!ENTITY lol2 "&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;&lol;">
<!ENTITY lol3 "&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;&lol2;">
]>
<cas:serviceResponse xmlns:cas="http://www.yale.edu/tp/cas">
<cas:authenticationSuccess>
<cas:user>&lol3;</cas:user>
</cas:authenticationSuccess>
</cas:serviceResponse>
""";
mockServer.expect(requestTo(containsString("/serviceValidate")))
.andRespond(withSuccess(billionLaughs, MediaType.APPLICATION_XML));
// disallow-doctype-decl=true means the parser refuses any DOCTYPE; we expect a validation
// exception rather than the parser dutifully expanding billions of entities.
assertThatThrownBy(() -> validator.validate("ST-laugh"))
.isInstanceOf(CasValidationException.class);
}
}

View file

@ -0,0 +1,78 @@
package com.iflytek.skillhub.auth.identity;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import com.iflytek.skillhub.auth.oauth.AccountPendingException;
import com.iflytek.skillhub.auth.oauth.OAuthClaims;
import com.iflytek.skillhub.auth.policy.AccessDecision;
import com.iflytek.skillhub.auth.policy.AccessPolicy;
import com.iflytek.skillhub.auth.rbac.PlatformPrincipal;
import com.iflytek.skillhub.domain.user.UserStatus;
import java.util.Map;
import java.util.Set;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
@ExtendWith(MockitoExtension.class)
class IdentityAuthenticatorTest {
@Mock
private AccessPolicy accessPolicy;
@Mock
private IdentityBindingService bindingService;
private IdentityAuthenticator authenticator;
@BeforeEach
void setUp() {
authenticator = new IdentityAuthenticator(accessPolicy, bindingService);
}
@Test
void authenticate_allowDecision_delegatesToBindOrCreate() {
IdentityClaims claims = new OAuthClaims("github", "gh_1", "u@example.com", true, "user", Map.of());
PlatformPrincipal expected = new PlatformPrincipal("usr_1", "user", "u@example.com", null, "github", Set.of("USER"));
when(accessPolicy.evaluate(claims)).thenReturn(AccessDecision.ALLOW);
when(bindingService.bindOrCreate(claims, UserStatus.ACTIVE)).thenReturn(expected);
PlatformPrincipal result = authenticator.authenticate(claims);
assertThat(result).isSameAs(expected);
}
@Test
void authenticate_pendingDecision_createsPendingUserAndThrows() {
IdentityClaims claims = new OAuthClaims("cas", "user-x", "x@example.com", true, "X", Map.of());
when(accessPolicy.evaluate(claims)).thenReturn(AccessDecision.PENDING_APPROVAL);
assertThatThrownBy(() -> authenticator.authenticate(claims))
.isInstanceOf(AccountPendingException.class);
verify(bindingService).createPendingUserIfAbsent(claims);
verify(bindingService, never()).bindOrCreate(any(), any());
}
@Test
void authenticate_denyDecision_throwsAccessDeniedByPolicy_andDoesNotBind() {
IdentityClaims claims = new OAuthClaims("cas", "user-y", "y@bad.example", true, "Y", Map.of());
when(accessPolicy.evaluate(claims)).thenReturn(AccessDecision.DENY);
assertThatThrownBy(() -> authenticator.authenticate(claims))
.isInstanceOf(AccessDeniedByPolicyException.class);
verify(bindingService, never()).bindOrCreate(any(), any());
verify(bindingService, never()).createPendingUserIfAbsent(any());
}
}

View file

@ -1,7 +1,6 @@
package com.iflytek.skillhub.auth.oauth;
import com.iflytek.skillhub.auth.identity.IdentityBindingService;
import com.iflytek.skillhub.auth.policy.AccessPolicy;
import com.iflytek.skillhub.auth.identity.IdentityAuthenticator;
import jakarta.servlet.http.HttpSession;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
@ -32,8 +31,7 @@ class OAuth2AuthorizationRequestResolverTest {
.build();
OAuthLoginFlowService oauthLoginFlowService = new OAuthLoginFlowService(
java.util.List.of(),
mock(AccessPolicy.class),
mock(IdentityBindingService.class)
mock(IdentityAuthenticator.class)
);
resolver = new SkillHubOAuth2AuthorizationRequestResolver(
new InMemoryClientRegistrationRepository(github),

View file

@ -1,7 +1,6 @@
package com.iflytek.skillhub.auth.oauth;
import com.iflytek.skillhub.auth.identity.IdentityBindingService;
import com.iflytek.skillhub.auth.policy.AccessPolicy;
import com.iflytek.skillhub.auth.identity.IdentityAuthenticator;
import jakarta.servlet.http.HttpSession;
import java.util.List;
import org.junit.jupiter.api.Test;
@ -18,8 +17,7 @@ class OAuthLoginFlowServiceTest {
void rememberReturnTo_stores_sanitized_return_target() {
OAuthLoginFlowService service = new OAuthLoginFlowService(
List.of(),
mock(AccessPolicy.class),
mock(IdentityBindingService.class)
mock(IdentityAuthenticator.class)
);
MockHttpServletRequest request = new MockHttpServletRequest();
request.setParameter("returnTo", "/dashboard/publish");
@ -36,8 +34,7 @@ class OAuthLoginFlowServiceTest {
void resolveFailureRedirect_maps_access_denied_to_user_facing_page() {
OAuthLoginFlowService service = new OAuthLoginFlowService(
List.of(),
mock(AccessPolicy.class),
mock(IdentityBindingService.class)
mock(IdentityAuthenticator.class)
);
String redirect = service.resolveFailureRedirect(
@ -52,8 +49,7 @@ class OAuthLoginFlowServiceTest {
void consumeReturnTo_clearsUnsafeSessionValue() {
OAuthLoginFlowService service = new OAuthLoginFlowService(
List.of(),
mock(AccessPolicy.class),
mock(IdentityBindingService.class)
mock(IdentityAuthenticator.class)
);
MockHttpServletRequest request = new MockHttpServletRequest();
HttpSession session = request.getSession(true);

View file

@ -15,10 +15,9 @@ set -eu
: "${SKILLHUB_WEB_AUTH_SESSION_BOOTSTRAP_ENABLED:=false}"
: "${SKILLHUB_WEB_AUTH_SESSION_BOOTSTRAP_PROVIDER:=}"
: "${SKILLHUB_WEB_AUTH_SESSION_BOOTSTRAP_AUTO:=false}"
: "${SKILLHUB_WEB_AUTH_CAS_ENABLED:=false}"
# Generate runtime-config.js
envsubst '${SKILLHUB_WEB_API_BASE_URL} ${SKILLHUB_PUBLIC_BASE_URL} ${SKILLHUB_WEB_AUTH_DIRECT_ENABLED} ${SKILLHUB_WEB_AUTH_DIRECT_PROVIDER} ${SKILLHUB_WEB_AUTH_SESSION_BOOTSTRAP_ENABLED} ${SKILLHUB_WEB_AUTH_SESSION_BOOTSTRAP_PROVIDER} ${SKILLHUB_WEB_AUTH_SESSION_BOOTSTRAP_AUTO} ${SKILLHUB_WEB_AUTH_CAS_ENABLED}' \
envsubst '${SKILLHUB_WEB_API_BASE_URL} ${SKILLHUB_PUBLIC_BASE_URL} ${SKILLHUB_WEB_AUTH_DIRECT_ENABLED} ${SKILLHUB_WEB_AUTH_DIRECT_PROVIDER} ${SKILLHUB_WEB_AUTH_SESSION_BOOTSTRAP_ENABLED} ${SKILLHUB_WEB_AUTH_SESSION_BOOTSTRAP_PROVIDER} ${SKILLHUB_WEB_AUTH_SESSION_BOOTSTRAP_AUTO}' \
< /usr/share/nginx/html/runtime-config.js.template \
> /usr/share/nginx/html/runtime-config.js

View file

@ -5,6 +5,5 @@ window.__SKILLHUB_RUNTIME_CONFIG__ = {
authDirectProvider: "${SKILLHUB_WEB_AUTH_DIRECT_PROVIDER}",
authSessionBootstrapEnabled: "${SKILLHUB_WEB_AUTH_SESSION_BOOTSTRAP_ENABLED}",
authSessionBootstrapProvider: "${SKILLHUB_WEB_AUTH_SESSION_BOOTSTRAP_PROVIDER}",
authSessionBootstrapAuto: "${SKILLHUB_WEB_AUTH_SESSION_BOOTSTRAP_AUTO}",
authCasEnabled: "${SKILLHUB_WEB_AUTH_CAS_ENABLED}"
authSessionBootstrapAuto: "${SKILLHUB_WEB_AUTH_SESSION_BOOTSTRAP_AUTO}"
};

View file

@ -63,7 +63,6 @@ type RuntimeConfig = {
authSessionBootstrapEnabled?: string
authSessionBootstrapProvider?: string
authSessionBootstrapAuto?: string
authCasEnabled?: string
}
declare global {
@ -164,17 +163,6 @@ export function getSessionBootstrapRuntimeConfig(): SessionBootstrapRuntimeConfi
}
}
export type CasAuthRuntimeConfig = {
enabled: boolean
}
export function getCasAuthRuntimeConfig(): CasAuthRuntimeConfig {
const config = getRuntimeConfig()
return {
enabled: parseBooleanFlag(config.authCasEnabled),
}
}
type ApiEnvelope<T> = {
code: number
msg: string

View file

@ -17,7 +17,7 @@ export type OAuthProvider = Omit<components['schemas']['AuthProviderResponse'],
export interface AuthMethod {
id: string
methodType: 'PASSWORD' | 'OAUTH_REDIRECT' | 'DIRECT_PASSWORD' | 'SESSION_BOOTSTRAP' | string
methodType: 'PASSWORD' | 'OAUTH_REDIRECT' | 'CAS_REDIRECT' | 'DIRECT_PASSWORD' | 'SESSION_BOOTSTRAP' | string
provider: string
displayName: string
actionUrl: string

View file

@ -26,7 +26,6 @@ function ensureRuntimeConfigFallback() {
authSessionBootstrapEnabled: 'false',
authSessionBootstrapProvider: '',
authSessionBootstrapAuto: 'false',
authCasEnabled: 'false',
}
}
}

View file

@ -1,16 +1,41 @@
import { describe, expect, it } from 'vitest'
import * as loginButton from './login-button'
import type { AuthMethod } from '@/api/types'
import { LoginButton, isExternalRedirectMethod } from './login-button'
/**
* LoginButton is a React component that renders OAuth login buttons from backend-provided
* auth methods. It filters for OAUTH_REDIRECT method types and shows a loading state.
* There are no exported pure functions, constants, or data transformations to unit-test.
*
* Full rendering tests would require a React test renderer, QueryClient provider,
* and i18next setup. This file verifies the export surface.
*/
describe('login-button module exports', () => {
it('exports LoginButton component', () => {
expect(loginButton.LoginButton).toBeTypeOf('function')
function method(overrides: Partial<AuthMethod>): AuthMethod {
return {
id: overrides.id ?? 'test',
methodType: overrides.methodType ?? 'PASSWORD',
provider: overrides.provider ?? 'test',
displayName: overrides.displayName ?? 'Test',
actionUrl: overrides.actionUrl ?? '/test',
}
}
describe('isExternalRedirectMethod', () => {
it('matches OAUTH_REDIRECT methods', () => {
expect(isExternalRedirectMethod(method({ methodType: 'OAUTH_REDIRECT' }))).toBe(true)
})
it('matches CAS_REDIRECT methods', () => {
expect(isExternalRedirectMethod(method({ methodType: 'CAS_REDIRECT' }))).toBe(true)
})
it('rejects local password method', () => {
expect(isExternalRedirectMethod(method({ methodType: 'PASSWORD' }))).toBe(false)
})
it('rejects direct password method', () => {
expect(isExternalRedirectMethod(method({ methodType: 'DIRECT_PASSWORD' }))).toBe(false)
})
it('rejects session-bootstrap method', () => {
expect(isExternalRedirectMethod(method({ methodType: 'SESSION_BOOTSTRAP' }))).toBe(false)
})
})
describe('login-button module exports', () => {
it('exports LoginButton component', () => {
expect(LoginButton).toBeTypeOf('function')
})
})

View file

@ -1,5 +1,6 @@
import { useTranslation } from 'react-i18next'
import { Button } from '@/shared/ui/button'
import type { AuthMethod } from '@/api/types'
import { useAuthMethods } from './use-auth-methods'
interface LoginButtonProps {
@ -7,9 +8,14 @@ interface LoginButtonProps {
}
/**
* Returns the appropriate icon for a given OAuth provider.
* Method types this button renders. CAS uses the same redirect-and-callback shape as OAuth from
* the UI's perspective, so we treat both as external-redirect providers.
*/
function OAuthIcon({ provider }: { provider: string }) {
export function isExternalRedirectMethod(method: AuthMethod): boolean {
return method.methodType === 'OAUTH_REDIRECT' || method.methodType === 'CAS_REDIRECT'
}
function ExternalProviderIcon({ provider }: { provider: string }) {
const normalizedProvider = provider.toLowerCase()
return (
<img
@ -21,15 +27,13 @@ function OAuthIcon({ provider }: { provider: string }) {
}
/**
* Renders OAuth login buttons from the auth-method catalog returned by the backend.
* Renders external-IdP login buttons (OAuth and CAS) from the auth-method catalog.
*/
export function LoginButton({ returnTo }: LoginButtonProps) {
const { t } = useTranslation()
const { data, isLoading } = useAuthMethods(returnTo)
const providers = (data ?? []).filter(
(method) => method.methodType === 'OAUTH_REDIRECT' || method.methodType === 'CAS_REDIRECT',
)
const providers = (data ?? []).filter(isExternalRedirectMethod)
if (isLoading) {
return (
@ -53,7 +57,7 @@ export function LoginButton({ returnTo }: LoginButtonProps) {
window.location.href = provider.actionUrl
}}
>
<OAuthIcon provider={provider.provider} />
<ExternalProviderIcon provider={provider.provider} />
{t('loginButton.loginWith', { name: provider.displayName })}
</Button>
))}