diff --git a/src/main/java/com/cleverthis/authservice/controller/AuthController.java b/src/main/java/com/cleverthis/authservice/controller/AuthController.java index 2cd95bb..a4e20c8 100644 --- a/src/main/java/com/cleverthis/authservice/controller/AuthController.java +++ b/src/main/java/com/cleverthis/authservice/controller/AuthController.java @@ -4,6 +4,7 @@ import com.cleverthis.authservice.dto.LoginRequest; import com.cleverthis.authservice.dto.LogoutRequest; import com.cleverthis.authservice.dto.TokenResponse; import com.cleverthis.authservice.dto.VerificationResponse; +import com.cleverthis.authservice.exception.TokenVerificationException; import com.cleverthis.authservice.service.IdentityManagerService; import jakarta.validation.Valid; import lombok.extern.slf4j.Slf4j; @@ -11,6 +12,7 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.util.StringUtils; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestHeader; @@ -26,6 +28,13 @@ import reactor.core.publisher.Mono; @RequestMapping("/") @Slf4j public class AuthController { + // --- Constants for Header Names --- + private static final String HEADER_USER_ID = "X-User-Id"; + private static final String HEADER_USER_NAME = "X-User-Name"; + private static final String HEADER_USER_EMAIL = "X-User-Email"; + private static final String HEADER_USER_GROUPS = "X-User-Groups"; + // Add more headers as needed (e.g., X-User-Roles) + private final IdentityManagerService identityManagerService; @Autowired @@ -51,26 +60,42 @@ import reactor.core.publisher.Mono; loginRequest.getUsername(), e.getMessage())); } + /** * Handles token verification requests. Expects the access token in the Authorization header - * (Bearer scheme). + * (Bearer scheme). Returns full token introspection details. * - * @param authorizationHeader The full Authorization header value. + * @param authorizationHeader The full Authorization header value (e.g., "Bearer "). * @return ResponseEntity containing the VerificationResponse on success, or an error status * handled globally. */ @PostMapping("/verify") public Mono> verify( @RequestHeader(HttpHeaders.AUTHORIZATION) String authorizationHeader) { - log.info("Received token verification request"); - if (authorizationHeader == null || !authorizationHeader.startsWith("Bearer ")) { - log.warn("Verification request missing or invalid Bearer token format. {}", - authorizationHeader); - return Mono.just(ResponseEntity.status(HttpStatus.UNAUTHORIZED).build()); + Mono> result; + log.info("Received token verification request (/verify)"); + String accessToken = extractBearerToken(authorizationHeader); + if (accessToken == null) { + log.warn("Verification request missing or invalid Bearer token format, token : {}", authorizationHeader); + result = Mono.just(ResponseEntity.status(HttpStatus.UNAUTHORIZED).build()); + } else { + result = this.identityManagerService.verifyToken(accessToken).map(ResponseEntity::ok) + .doOnError(e -> log.error("Token verification failed: {}", e.getMessage())); } - String accessToken = authorizationHeader.substring(7); - return this.identityManagerService.verifyToken(accessToken).map(ResponseEntity::ok) - .doOnError(e -> log.error("Token verification failed: {}", e.getMessage())); + + return result; + } + + /** + * Extracts the token from the "Bearer " Authorization header. + * @param authorizationHeader The full header value. + * @return The token string or null if header is invalid. + */ + private String extractBearerToken(String authorizationHeader) { + if (authorizationHeader != null && authorizationHeader.startsWith("Bearer ")) { + return authorizationHeader.substring(7); + } + return null; } /** @@ -86,4 +111,73 @@ import reactor.core.publisher.Mono; .thenReturn(ResponseEntity.noContent().build()) .doOnError(e -> log.error("Logout failed: {}", e.getMessage())); } + + /** + * Handles forward authentication requests from Traefik. Expects the access token in the + * Authorization header (Bearer scheme). On success, returns 200 OK with user metadata in + * headers. On failure, returns 401 Unauthorized. + * + * @param authorizationHeader The full Authorization header value (e.g., "Bearer "). + * @return ResponseEntity with status 200 and custom headers on success, or 401/500 on failure. + */ + // CHANGED: Use POST instead of GET for diagnostic purposes + @PostMapping("/auth") + public Mono> forwardAuth( + @RequestHeader(HttpHeaders.AUTHORIZATION) String authorizationHeader) { + log.info("Received forwardAuth request (/auth)"); + String accessToken = extractBearerToken(authorizationHeader); + if (accessToken == null) { + log.warn("ForwardAuth request missing or invalid Bearer token format, token : {}", authorizationHeader); + // Return 401 which Traefik forwardAuth typically interprets as "block request" + return Mono.just(ResponseEntity.status(HttpStatus.UNAUTHORIZED).build()); + } + + return this.identityManagerService.verifyToken(accessToken).flatMap(verificationResponse -> { + // If token is valid and active, build success response with headers + log.debug("ForwardAuth successful for user: {}", + verificationResponse.getPreferredUsername()); + HttpHeaders responseHeaders = buildAuthHeaders(verificationResponse); + // Return 200 OK with the headers, no body needed. + return Mono.just(ResponseEntity.ok().headers(responseHeaders).build()); + }).doOnError(e -> log.error("ForwardAuth token verification failed: {}", e.getMessage())) + // On specific verification error (invalid token), return 401 + .onErrorResume(TokenVerificationException.class, + e -> Mono + .just(ResponseEntity.status(HttpStatus.UNAUTHORIZED).build())) + // Catch other potential errors during service call (e.g., Keycloak unavailable) + .onErrorResume(Exception.class, e -> { + log.error("Internal error during forwardAuth token verification: {}", + e.getMessage(), e); + // Return 500 or 401? Returning 401 might be safer for auth checks. + return Mono.just(ResponseEntity.status(HttpStatus.UNAUTHORIZED).build()); + }); + } + + /** + * Builds HttpHeaders containing user metadata based on the verification response. + * @param verificationResponse The response from token verification. + * @return HttpHeaders object with X-User-* headers. + */ + private HttpHeaders buildAuthHeaders(VerificationResponse verificationResponse) { + HttpHeaders headers = new HttpHeaders(); + // Use 'sub' (subject) as the canonical User ID + if (verificationResponse.getSub() != null) { + headers.add(HEADER_USER_ID, verificationResponse.getSub()); + } + // Use 'preferredUsername' as the display/login name + if (verificationResponse.getPreferredUsername() != null) { + headers.add(HEADER_USER_NAME, verificationResponse.getPreferredUsername()); + } + if (verificationResponse.getEmail() != null) { + headers.add(HEADER_USER_EMAIL, verificationResponse.getEmail()); + } + // Handle groups if present + if (verificationResponse.getGroups() != null + && !verificationResponse.getGroups().isEmpty()) { + // Convert list of groups to a comma-separated string + headers.add(HEADER_USER_GROUPS, + StringUtils.collectionToCommaDelimitedString(verificationResponse.getGroups())); + } + return headers; + } } diff --git a/src/main/java/com/cleverthis/authservice/dto/VerificationResponse.java b/src/main/java/com/cleverthis/authservice/dto/VerificationResponse.java index 02cbac1..df33f2c 100644 --- a/src/main/java/com/cleverthis/authservice/dto/VerificationResponse.java +++ b/src/main/java/com/cleverthis/authservice/dto/VerificationResponse.java @@ -1,5 +1,9 @@ package com.cleverthis.authservice.dto; +import java.util.List; + +import com.fasterxml.jackson.annotation.JsonProperty; + import lombok.AllArgsConstructor; import lombok.Data; import lombok.NoArgsConstructor; @@ -20,11 +24,12 @@ public class VerificationResponse { private long exp; // Expiration timestamp private long iat; // Issued at timestamp private long nbf; // Not before timestamp - private String aud; // Audience + private List aud; // Audience - Can be a list if token has multiple audiences private String iss; // Issuer private String typ; // Type (e.g., Bearer) private String email; // Standard OIDC claim private Boolean emailVerified; // Standard OIDC claim + @JsonProperty("preferred_username") private String preferredUsername; // Standard OIDC claim - Use this for X-User-Name - + private List groups; } diff --git a/src/test/java/com/cleverthis/authservice/AuthApplicationTests.java b/src/test/java/com/cleverthis/authservice/AuthApplicationTests.java index a673d6c..4f4b83c 100644 --- a/src/test/java/com/cleverthis/authservice/AuthApplicationTests.java +++ b/src/test/java/com/cleverthis/authservice/AuthApplicationTests.java @@ -13,6 +13,8 @@ import com.cleverthis.authservice.exception.GlobalExceptionHandler; import com.cleverthis.authservice.exception.LogoutException; import com.cleverthis.authservice.exception.TokenVerificationException; import com.cleverthis.authservice.service.IdentityManagerService; +import java.util.Arrays; +import java.util.List; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.beans.factory.annotation.Autowired; @@ -29,7 +31,8 @@ import reactor.core.publisher.Mono; * controller logic and request/response handling. */ @WebFluxTest(AuthController.class) -@Import(GlobalExceptionHandler.class) class AuthControllerTest { +@Import(GlobalExceptionHandler.class) +class AuthControllerTest { @Autowired private WebTestClient webTestClient; // Test client for making requests to the controller @@ -39,16 +42,32 @@ import reactor.core.publisher.Mono; private TokenResponse mockTokenResponse; private VerificationResponse mockVerificationResponse; + private VerificationResponse mockVerificationResponseWithGroups; + + // --- Constants for Header Names (mirroring controller) --- + private static final String HEADER_USER_ID = "X-User-Id"; + private static final String HEADER_USER_NAME = "X-User-Name"; + private static final String HEADER_USER_EMAIL = "X-User-Email"; + private static final String HEADER_USER_GROUPS = "X-User-Groups"; @BeforeEach void setUp() { // Setup mock responses for successful scenarios this.mockTokenResponse = new TokenResponse("access-123", 300, 1800, "refresh-456", "Bearer", "id-789", 0, "state", "openid"); + + // Mock response without groups this.mockVerificationResponse = new VerificationResponse(true, "test-client", "testuser", - "openid", "sub-123", System.currentTimeMillis() / 1000 + 300, - System.currentTimeMillis() / 1000, System.currentTimeMillis() / 1000, "aud", "iss", - "Bearer", "test@example.com", true, "testuser"); + "openid", "user-sub-123", System.currentTimeMillis() / 1000 + 300, + System.currentTimeMillis() / 1000, System.currentTimeMillis() / 1000, + Arrays.asList("aud"), "iss", "Bearer", "test@example.com", true, "testuser", null); + + // Mock response with groups + this.mockVerificationResponseWithGroups = + new VerificationResponse(true, "test-client", "groupuser", "openid", "user-sub-456", + System.currentTimeMillis() / 1000 + 300, System.currentTimeMillis() / 1000, + System.currentTimeMillis() / 1000, Arrays.asList("aud"), "iss", "Bearer", + "group@example.com", true, "groupuser", List.of("group1", "group2")); } // --- /login Tests --- @@ -73,13 +92,13 @@ import reactor.core.publisher.Mono; when(this.identityManagerService.login("wronguser", "wrongpass")) .thenReturn(Mono.error(new AuthenticationException("Invalid credentials"))); - this. webTestClient.post().uri("/login").contentType(MediaType.APPLICATION_JSON) - .bodyValue(loginRequest).exchange().expectStatus().isUnauthorized(); // Expect 401 + this.webTestClient.post().uri("/login").contentType(MediaType.APPLICATION_JSON) + .bodyValue(loginRequest).exchange().expectStatus().isUnauthorized(); } @Test void login_InvalidRequest_ReturnsBadRequest() { - LoginRequest loginRequest = new LoginRequest("", ""); + LoginRequest loginRequest = new LoginRequest("", ""); // Invalid request // No need to mock service for validation errors // The framework and GlobalExceptionHandler (via parent) should handle this @@ -124,10 +143,88 @@ import reactor.core.publisher.Mono; @Test void verify_InvalidAuthHeaderFormat_ReturnsUnauthorized() { - // No service mocking needed, controller handles this directly + // Controller logic handles this case specifically this.webTestClient.post().uri("/verify") .header(HttpHeaders.AUTHORIZATION, "Basic somecredentials") // Wrong scheme - .exchange().expectStatus().isUnauthorized(); // Expect 401 + .exchange().expectStatus().isUnauthorized(); // Expect 401 (handled by controller + } + + // --- /auth (ForwardAuth) Tests --- + + @Test + void forwardAuth_Success_ReturnsOkWithHeaders() { + String validToken = "valid-token-fw-123"; + // Use mock response with groups for this test + when(this.identityManagerService.verifyToken(validToken)) + .thenReturn(Mono.just(this.mockVerificationResponseWithGroups)); + + // CHANGED: Use POST for /auth tests + this.webTestClient.post().uri("/auth") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + validToken).exchange().expectStatus() + .isOk() // Expect 200 OK + .expectHeader().valueEquals(HEADER_USER_ID, "user-sub-456").expectHeader() + .valueEquals(HEADER_USER_NAME, "groupuser").expectHeader() + .valueEquals(HEADER_USER_EMAIL, "group@example.com").expectHeader() + .valueEquals(HEADER_USER_GROUPS, "group1,group2") // Comma-separated + .expectBody().isEmpty(); // No body expected + } + + @Test + void forwardAuth_Success_ReturnsOkWithHeaders_NoGroups() { + String validToken = "valid-token-fw-nogroups"; + // Use mock response without groups + when(this.identityManagerService.verifyToken(validToken)) + .thenReturn(Mono.just(this.mockVerificationResponse)); + + // CHANGED: Use POST for /auth tests + this.webTestClient.post().uri("/auth") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + validToken).exchange().expectStatus() + .isOk() // Expect 200 OK + .expectHeader().valueEquals(HEADER_USER_ID, "user-sub-123").expectHeader() + .valueEquals(HEADER_USER_NAME, "testuser").expectHeader() + .valueEquals(HEADER_USER_EMAIL, "test@example.com").expectHeader() + .doesNotExist(HEADER_USER_GROUPS) // Groups header should not exist + .expectBody().isEmpty(); + } + + @Test + void forwardAuth_ServiceThrowsVerificationException_ReturnsUnauthorized() { + String invalidToken = "invalid-token-fw-456"; + when(this.identityManagerService.verifyToken(invalidToken)) + .thenReturn(Mono.error(new TokenVerificationException("Token expired"))); + + // CHANGED: Use POST for /auth tests + this.webTestClient.post().uri("/auth") + .header(HttpHeaders.AUTHORIZATION, "Bearer " + invalidToken).exchange() + .expectStatus().isUnauthorized(); // Expect 401 (Handled by controller + } + + @Test + void forwardAuth_ServiceThrowsGenericException_ReturnsUnauthorized() { + String token = "token-causing-error"; + // Mock service to throw a generic exception + when(this.identityManagerService.verifyToken(token)) + .thenReturn(Mono.error(new RuntimeException("Unexpected service error"))); + + // CHANGED: Use POST for /auth tests + this.webTestClient.post().uri("/auth").header(HttpHeaders.AUTHORIZATION, "Bearer " + token) + .exchange().expectStatus().isUnauthorized(); // Expect 401 + } + + @Test + void forwardAuth_MissingAuthHeader_ReturnsBadRequest() { // UPDATED EXPECTATION + // Framework handles missing required header + // CHANGED: Use POST for /auth tests + this.webTestClient.post().uri("/auth").exchange().expectStatus().isBadRequest(); + } + + @Test + void forwardAuth_InvalidAuthHeaderFormat_ReturnsUnauthorized() { + // Controller logic handles this case specifically + // CHANGED: Use POST for /auth tests + this.webTestClient.post().uri("/auth") + .header(HttpHeaders.AUTHORIZATION, "Basic somecredentials").exchange() + .expectStatus().isUnauthorized(); // Expect 401 (Handled by controller) } // --- /logout Tests --- @@ -136,7 +233,7 @@ import reactor.core.publisher.Mono; void logout_Success_ReturnsNoContent() { LogoutRequest logoutRequest = new LogoutRequest("refresh-token-to-revoke"); when(this.identityManagerService.logout(logoutRequest.getRefreshToken())) - .thenReturn(Mono.empty()); // Mono completes successfully + .thenReturn(Mono.empty()); this.webTestClient.post().uri("/logout").contentType(MediaType.APPLICATION_JSON) .bodyValue(logoutRequest).exchange().expectStatus().isNoContent(); // Expect 204 @@ -150,17 +247,16 @@ import reactor.core.publisher.Mono; .thenReturn(Mono.error(new LogoutException("Keycloak unavailable"))); this.webTestClient.post().uri("/logout").contentType(MediaType.APPLICATION_JSON) - .bodyValue(logoutRequest).exchange().expectStatus().is5xxServerError(); + .bodyValue(logoutRequest).exchange().expectStatus().is5xxServerError(); } @Test void logout_InvalidRequest_ReturnsBadRequest() { - LogoutRequest logoutRequest = new LogoutRequest(""); - // Invalid request (blank token) + LogoutRequest logoutRequest = new LogoutRequest(""); // Invalid request (blank token) + // No service mocking needed this.webTestClient.post().uri("/logout").contentType(MediaType.APPLICATION_JSON) - .bodyValue(logoutRequest).exchange().expectStatus().isBadRequest(); + .bodyValue(logoutRequest).exchange().expectStatus().isBadRequest(); // Expect 400 } - }