diff --git a/src/main/java/dev/escalated/controllers/api/ApiAuthController.java b/src/main/java/dev/escalated/controllers/api/ApiAuthController.java new file mode 100644 index 0000000..dce27f8 --- /dev/null +++ b/src/main/java/dev/escalated/controllers/api/ApiAuthController.java @@ -0,0 +1,120 @@ +package dev.escalated.controllers.api; + +import dev.escalated.security.EscalatedApiAuthenticator; +import java.util.Map; +import java.util.Optional; +import java.util.function.Supplier; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestHeader; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +/** + * General JSON API authentication for the Flutter app and integrations. All + * credential handling is delegated to a host-provided + * {@link EscalatedApiAuthenticator} bean — Escalated owns no passwords or + * sessions. No bean (or an unimplemented method) responds 501; a method + * returning {@code null} is an authentication failure (401). + * + *

login/register/refresh/logout are permitted without a token (see + * EscalatedSecurityConfig); me/profile use the bearer token. + */ +@RestController +@RequestMapping("/escalated/api/v1/auth") +public class ApiAuthController { + + private final EscalatedApiAuthenticator authenticator; + + public ApiAuthController(Optional authenticator) { + this.authenticator = authenticator.orElse(null); + } + + @PostMapping("/login") + public ResponseEntity login(@RequestBody(required = false) Map body) { + return delegate(() -> authenticator.authenticate(body == null ? Map.of() : body)); + } + + @PostMapping("/register") + public ResponseEntity register(@RequestBody(required = false) Map body) { + return delegate(() -> authenticator.register(body == null ? Map.of() : body)); + } + + @PostMapping("/refresh") + public ResponseEntity refresh(@RequestHeader(value = "Authorization", required = false) String auth) { + return delegate(() -> authenticator.refresh(bearer(auth))); + } + + @GetMapping("/me") + public ResponseEntity me(@RequestHeader(value = "Authorization", required = false) String auth) { + return delegate(() -> authenticator.validate(bearer(auth))); + } + + @PatchMapping("/profile") + public ResponseEntity profile( + @RequestHeader(value = "Authorization", required = false) String auth, + @RequestBody(required = false) Map body) { + return delegate(() -> authenticator.updateProfile(bearer(auth), body == null ? Map.of() : body)); + } + + @PostMapping("/logout") + public ResponseEntity logout(@RequestHeader(value = "Authorization", required = false) String auth) { + if (authenticator != null) { + try { + authenticator.logout(bearer(auth)); + } catch (UnsupportedOperationException ignored) { + // Best-effort: logout always succeeds. + } + } + return ResponseEntity.ok(Map.of("data", Map.of("success", true))); + } + + @PostMapping("/validate") + public ResponseEntity validate(@RequestBody(required = false) Map body) { + Object token = body == null ? null : body.get("token"); + if (token == null || token.toString().isEmpty()) { + return ResponseEntity.badRequest().body(Map.of("error", "token is required")); + } + if (authenticator == null) { + return notConfigured(); + } + try { + Map user = authenticator.validate(token.toString()); + if (user == null) { + return ResponseEntity.status(401).body(Map.of("error", "Invalid token")); + } + return ResponseEntity.ok(Map.of("valid", true, "user", user)); + } catch (UnsupportedOperationException e) { + return notConfigured(); + } + } + + private ResponseEntity delegate(Supplier> call) { + if (authenticator == null) { + return notConfigured(); + } + try { + Map result = call.get(); + if (result == null) { + return ResponseEntity.status(401).body(Map.of("error", "Unauthorized")); + } + return ResponseEntity.ok(Map.of("data", result)); + } catch (UnsupportedOperationException e) { + return notConfigured(); + } + } + + private ResponseEntity notConfigured() { + return ResponseEntity.status(501).body(Map.of("error", "Authentication is not configured")); + } + + private String bearer(String header) { + if (header == null) { + return ""; + } + return header.startsWith("Bearer ") ? header.substring(7).trim() : header; + } +} diff --git a/src/main/java/dev/escalated/security/EscalatedApiAuthenticator.java b/src/main/java/dev/escalated/security/EscalatedApiAuthenticator.java new file mode 100644 index 0000000..0f73a6c --- /dev/null +++ b/src/main/java/dev/escalated/security/EscalatedApiAuthenticator.java @@ -0,0 +1,47 @@ +package dev.escalated.security; + +import java.util.Map; + +/** + * Host-app authentication callbacks for the general JSON API + * ({@code /escalated/api/v1/auth/*}) consumed by the Flutter app. Provide a + * Spring {@code @Bean} implementing the methods your app needs — Escalated owns + * no credentials or sessions, so it ships no password-hashing dependency. + * + *

Each method returns the JSON payload to send (e.g. token + user) on + * success, or {@code null} for an authentication failure (401). An + * unimplemented method (the default throws {@link UnsupportedOperationException}) + * — or no bean at all — makes its endpoint respond {@code 501}. + */ +public interface EscalatedApiAuthenticator { + + /** Authenticate a login request (email/password etc.). */ + default Map authenticate(Map params) { + throw new UnsupportedOperationException(); + } + + /** Register a new account. */ + default Map register(Map params) { + throw new UnsupportedOperationException(); + } + + /** Validate a token and return the associated user. */ + default Map validate(String token) { + throw new UnsupportedOperationException(); + } + + /** Exchange/refresh a token. */ + default Map refresh(String token) { + throw new UnsupportedOperationException(); + } + + /** Update the authenticated user's profile. */ + default Map updateProfile(String token, Map attrs) { + throw new UnsupportedOperationException(); + } + + /** Invalidate a token (best-effort; default no-op). */ + default void logout(String token) { + // No-op by default. + } +} diff --git a/src/main/java/dev/escalated/security/EscalatedSecurityConfig.java b/src/main/java/dev/escalated/security/EscalatedSecurityConfig.java index 92d5fb8..e7a2a77 100644 --- a/src/main/java/dev/escalated/security/EscalatedSecurityConfig.java +++ b/src/main/java/dev/escalated/security/EscalatedSecurityConfig.java @@ -31,6 +31,12 @@ public SecurityFilterChain escalatedApiSecurityFilterChain(HttpSecurity http) th .requestMatchers("/escalated/api/widget/**").permitAll() .requestMatchers("/escalated/api/csat/**").permitAll() .requestMatchers("/escalated/api/guest/**").permitAll() + // Public auth endpoints carry no token yet. + .requestMatchers( + "/escalated/api/v1/auth/login", + "/escalated/api/v1/auth/register", + "/escalated/api/v1/auth/refresh", + "/escalated/api/v1/auth/logout").permitAll() .anyRequest().authenticated() ); return http.build(); diff --git a/src/test/java/dev/escalated/controllers/api/ApiAuthControllerTest.java b/src/test/java/dev/escalated/controllers/api/ApiAuthControllerTest.java new file mode 100644 index 0000000..13cbe5a --- /dev/null +++ b/src/test/java/dev/escalated/controllers/api/ApiAuthControllerTest.java @@ -0,0 +1,95 @@ +package dev.escalated.controllers.api; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +import dev.escalated.security.EscalatedApiAuthenticator; +import java.util.Map; +import java.util.Optional; +import org.junit.jupiter.api.Test; +import org.springframework.http.ResponseEntity; + +class ApiAuthControllerTest { + + @Test + void loginReturns501WhenNoBean() { + ApiAuthController ctrl = new ApiAuthController(Optional.empty()); + assertEquals(501, ctrl.login(Map.of()).getStatusCode().value()); + } + + @Test + void loginDelegatesToAuthenticator() { + EscalatedApiAuthenticator auth = new EscalatedApiAuthenticator() { + @Override + public Map authenticate(Map params) { + return Map.of("token", "abc", "email", params.get("email")); + } + }; + ApiAuthController ctrl = new ApiAuthController(Optional.of(auth)); + + ResponseEntity res = ctrl.login(Map.of("email", "a@b.com")); + + assertEquals(200, res.getStatusCode().value()); + assertEquals(Map.of("data", Map.of("token", "abc", "email", "a@b.com")), res.getBody()); + } + + @Test + void loginReturns401WhenAuthenticatorReturnsNull() { + EscalatedApiAuthenticator auth = new EscalatedApiAuthenticator() { + @Override + public Map authenticate(Map params) { + return null; + } + }; + ApiAuthController ctrl = new ApiAuthController(Optional.of(auth)); + + assertEquals(401, ctrl.login(Map.of()).getStatusCode().value()); + } + + @Test + void unimplementedMethodReturns501() { + EscalatedApiAuthenticator auth = new EscalatedApiAuthenticator() { + @Override + public Map authenticate(Map params) { + return Map.of("ok", true); + } + }; + ApiAuthController ctrl = new ApiAuthController(Optional.of(auth)); + + assertEquals(501, ctrl.register(Map.of()).getStatusCode().value()); + } + + @Test + void meForwardsBearerToken() { + final String[] seen = {null}; + EscalatedApiAuthenticator auth = new EscalatedApiAuthenticator() { + @Override + public Map validate(String token) { + seen[0] = token; + return Map.of("id", 7); + } + }; + ApiAuthController ctrl = new ApiAuthController(Optional.of(auth)); + + ResponseEntity res = ctrl.me("Bearer tok123"); + + assertEquals(200, res.getStatusCode().value()); + assertEquals("tok123", seen[0]); + } + + @Test + void logoutAlwaysSucceeds() { + ApiAuthController ctrl = new ApiAuthController(Optional.empty()); + + ResponseEntity res = ctrl.logout("Bearer x"); + + assertEquals(200, res.getStatusCode().value()); + assertEquals(Map.of("data", Map.of("success", true)), res.getBody()); + } + + @Test + void validateRequiresToken() { + ApiAuthController ctrl = new ApiAuthController(Optional.of(new EscalatedApiAuthenticator() {})); + + assertEquals(400, ctrl.validate(Map.of()).getStatusCode().value()); + } +}