Contract-first API with DTOs, validation, and server-side OpenAPI post-processing
Add dedicated request/response DTOs for all controllers, replacing raw JsonNode parameters with validated types. Move OpenAPI path-prefix stripping and ProcessorNode children injection into OpenApiCustomizer beans so the spec served at /api/v1/api-docs is already clean — eliminating the need for the ui/scripts/process-openapi.mjs post-processing script. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,24 +1,32 @@
|
||||
package com.cameleer3.server.app.security;
|
||||
|
||||
import com.cameleer3.server.app.dto.AuthTokenResponse;
|
||||
import com.cameleer3.server.app.dto.ErrorResponse;
|
||||
import com.cameleer3.server.app.dto.OidcPublicConfigResponse;
|
||||
import com.cameleer3.server.core.security.JwtService;
|
||||
import com.cameleer3.server.core.security.OidcConfig;
|
||||
import com.cameleer3.server.core.security.OidcConfigRepository;
|
||||
import com.cameleer3.server.core.security.UserInfo;
|
||||
import com.cameleer3.server.core.security.UserRepository;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.media.Content;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.net.URI;
|
||||
import java.time.Instant;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
@@ -29,6 +37,7 @@ import java.util.Optional;
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/auth/oidc")
|
||||
@Tag(name = "Authentication", description = "Login and token refresh endpoints")
|
||||
public class OidcAuthController {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(OidcAuthController.class);
|
||||
@@ -53,7 +62,12 @@ public class OidcAuthController {
|
||||
* Returns 404 if OIDC is not configured or disabled.
|
||||
*/
|
||||
@GetMapping("/config")
|
||||
public ResponseEntity<?> getConfig() {
|
||||
@Operation(summary = "Get OIDC config for SPA login flow")
|
||||
@ApiResponse(responseCode = "200", description = "OIDC configuration")
|
||||
@ApiResponse(responseCode = "404", description = "OIDC not configured or disabled")
|
||||
@ApiResponse(responseCode = "500", description = "Failed to retrieve OIDC provider metadata",
|
||||
content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
|
||||
public ResponseEntity<OidcPublicConfigResponse> getConfig() {
|
||||
Optional<OidcConfig> config = configRepository.find();
|
||||
if (config.isEmpty() || !config.get().enabled()) {
|
||||
return ResponseEntity.notFound().build();
|
||||
@@ -61,19 +75,17 @@ public class OidcAuthController {
|
||||
|
||||
try {
|
||||
OidcConfig oidc = config.get();
|
||||
Map<String, Object> response = new LinkedHashMap<>();
|
||||
response.put("issuer", oidc.issuerUri());
|
||||
response.put("clientId", oidc.clientId());
|
||||
response.put("authorizationEndpoint", tokenExchanger.getAuthorizationEndpoint());
|
||||
String endSessionEndpoint = tokenExchanger.getEndSessionEndpoint();
|
||||
if (endSessionEndpoint != null) {
|
||||
response.put("endSessionEndpoint", endSessionEndpoint);
|
||||
}
|
||||
return ResponseEntity.ok(response);
|
||||
return ResponseEntity.ok(new OidcPublicConfigResponse(
|
||||
oidc.issuerUri(),
|
||||
oidc.clientId(),
|
||||
tokenExchanger.getAuthorizationEndpoint(),
|
||||
endSessionEndpoint
|
||||
));
|
||||
} catch (Exception e) {
|
||||
log.error("Failed to retrieve OIDC provider metadata: {}", e.getMessage());
|
||||
return ResponseEntity.internalServerError()
|
||||
.body(Map.of("message", "Failed to retrieve OIDC provider metadata"));
|
||||
throw new ResponseStatusException(HttpStatus.INTERNAL_SERVER_ERROR,
|
||||
"Failed to retrieve OIDC provider metadata");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,7 +93,14 @@ public class OidcAuthController {
|
||||
* Exchanges an OIDC authorization code for internal Cameleer JWTs.
|
||||
*/
|
||||
@PostMapping("/callback")
|
||||
public ResponseEntity<?> callback(@RequestBody CallbackRequest request) {
|
||||
@Operation(summary = "Exchange OIDC authorization code for JWTs")
|
||||
@ApiResponse(responseCode = "200", description = "Authentication successful")
|
||||
@ApiResponse(responseCode = "401", description = "OIDC authentication failed",
|
||||
content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
|
||||
@ApiResponse(responseCode = "403", description = "Account not provisioned",
|
||||
content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
|
||||
@ApiResponse(responseCode = "404", description = "OIDC not configured or disabled")
|
||||
public ResponseEntity<AuthTokenResponse> callback(@RequestBody CallbackRequest request) {
|
||||
Optional<OidcConfig> config = configRepository.find();
|
||||
if (config.isEmpty() || !config.get().enabled()) {
|
||||
return ResponseEntity.notFound().build();
|
||||
@@ -98,8 +117,8 @@ public class OidcAuthController {
|
||||
// Check auto-signup gate: if disabled, user must already exist
|
||||
Optional<UserInfo> existingUser = userRepository.findById(userId);
|
||||
if (!config.get().autoSignup() && existingUser.isEmpty()) {
|
||||
return ResponseEntity.status(403)
|
||||
.body(Map.of("message", "Account not provisioned. Contact your administrator."));
|
||||
throw new ResponseStatusException(HttpStatus.FORBIDDEN,
|
||||
"Account not provisioned. Contact your administrator.");
|
||||
}
|
||||
|
||||
// Resolve roles: DB override > OIDC claim > default
|
||||
@@ -111,14 +130,13 @@ public class OidcAuthController {
|
||||
String accessToken = jwtService.createAccessToken(userId, "ui", roles);
|
||||
String refreshToken = jwtService.createRefreshToken(userId, "ui", roles);
|
||||
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"accessToken", accessToken,
|
||||
"refreshToken", refreshToken
|
||||
));
|
||||
return ResponseEntity.ok(new AuthTokenResponse(accessToken, refreshToken));
|
||||
} catch (ResponseStatusException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
log.error("OIDC callback failed: {}", e.getMessage(), e);
|
||||
return ResponseEntity.status(401)
|
||||
.body(Map.of("message", "OIDC authentication failed: " + e.getMessage()));
|
||||
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED,
|
||||
"OIDC authentication failed: " + e.getMessage());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,20 +1,28 @@
|
||||
package com.cameleer3.server.app.security;
|
||||
|
||||
import com.cameleer3.server.app.dto.AuthTokenResponse;
|
||||
import com.cameleer3.server.app.dto.ErrorResponse;
|
||||
import com.cameleer3.server.core.security.JwtService;
|
||||
import com.cameleer3.server.core.security.JwtService.JwtValidationResult;
|
||||
import com.cameleer3.server.core.security.UserInfo;
|
||||
import com.cameleer3.server.core.security.UserRepository;
|
||||
import io.swagger.v3.oas.annotations.Operation;
|
||||
import io.swagger.v3.oas.annotations.media.Content;
|
||||
import io.swagger.v3.oas.annotations.media.Schema;
|
||||
import io.swagger.v3.oas.annotations.responses.ApiResponse;
|
||||
import io.swagger.v3.oas.annotations.tags.Tag;
|
||||
import org.slf4j.Logger;
|
||||
import org.slf4j.LoggerFactory;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.PostMapping;
|
||||
import org.springframework.web.bind.annotation.RequestBody;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
/**
|
||||
* Authentication endpoints for the UI (local credentials).
|
||||
@@ -25,6 +33,7 @@ import java.util.Map;
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/auth")
|
||||
@Tag(name = "Authentication", description = "Login and token refresh endpoints")
|
||||
public class UiAuthController {
|
||||
|
||||
private static final Logger log = LoggerFactory.getLogger(UiAuthController.class);
|
||||
@@ -41,20 +50,24 @@ public class UiAuthController {
|
||||
}
|
||||
|
||||
@PostMapping("/login")
|
||||
public ResponseEntity<?> login(@RequestBody LoginRequest request) {
|
||||
@Operation(summary = "Login with local credentials")
|
||||
@ApiResponse(responseCode = "200", description = "Login successful")
|
||||
@ApiResponse(responseCode = "401", description = "Invalid credentials",
|
||||
content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
|
||||
public ResponseEntity<AuthTokenResponse> login(@RequestBody LoginRequest request) {
|
||||
String configuredUser = properties.getUiUser();
|
||||
String configuredPassword = properties.getUiPassword();
|
||||
|
||||
if (configuredUser == null || configuredUser.isBlank()
|
||||
|| configuredPassword == null || configuredPassword.isBlank()) {
|
||||
log.warn("UI authentication attempted but CAMELEER_UI_USER / CAMELEER_UI_PASSWORD not configured");
|
||||
return ResponseEntity.status(401).body(Map.of("message", "UI authentication not configured"));
|
||||
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "UI authentication not configured");
|
||||
}
|
||||
|
||||
if (!configuredUser.equals(request.username())
|
||||
|| !configuredPassword.equals(request.password())) {
|
||||
log.debug("UI login failed for user: {}", request.username());
|
||||
return ResponseEntity.status(401).body(Map.of("message", "Invalid credentials"));
|
||||
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid credentials");
|
||||
}
|
||||
|
||||
String subject = "ui:" + request.username();
|
||||
@@ -72,18 +85,19 @@ public class UiAuthController {
|
||||
String refreshToken = jwtService.createRefreshToken(subject, "ui", roles);
|
||||
|
||||
log.info("UI user logged in: {}", request.username());
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"accessToken", accessToken,
|
||||
"refreshToken", refreshToken
|
||||
));
|
||||
return ResponseEntity.ok(new AuthTokenResponse(accessToken, refreshToken));
|
||||
}
|
||||
|
||||
@PostMapping("/refresh")
|
||||
public ResponseEntity<?> refresh(@RequestBody RefreshRequest request) {
|
||||
@Operation(summary = "Refresh access token")
|
||||
@ApiResponse(responseCode = "200", description = "Token refreshed")
|
||||
@ApiResponse(responseCode = "401", description = "Invalid refresh token",
|
||||
content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
|
||||
public ResponseEntity<AuthTokenResponse> refresh(@RequestBody RefreshRequest request) {
|
||||
try {
|
||||
JwtValidationResult result = jwtService.validateRefreshToken(request.refreshToken());
|
||||
if (!result.subject().startsWith("ui:")) {
|
||||
return ResponseEntity.status(401).body(Map.of("message", "Not a UI token"));
|
||||
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Not a UI token");
|
||||
}
|
||||
|
||||
// Preserve roles from the refresh token
|
||||
@@ -91,13 +105,12 @@ public class UiAuthController {
|
||||
String accessToken = jwtService.createAccessToken(result.subject(), "ui", roles);
|
||||
String refreshToken = jwtService.createRefreshToken(result.subject(), "ui", roles);
|
||||
|
||||
return ResponseEntity.ok(Map.of(
|
||||
"accessToken", accessToken,
|
||||
"refreshToken", refreshToken
|
||||
));
|
||||
return ResponseEntity.ok(new AuthTokenResponse(accessToken, refreshToken));
|
||||
} catch (ResponseStatusException e) {
|
||||
throw e;
|
||||
} catch (Exception e) {
|
||||
log.debug("UI token refresh failed: {}", e.getMessage());
|
||||
return ResponseEntity.status(401).body(Map.of("message", "Invalid refresh token"));
|
||||
throw new ResponseStatusException(HttpStatus.UNAUTHORIZED, "Invalid refresh token");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user