Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
120 changes: 120 additions & 0 deletions src/main/java/dev/escalated/controllers/api/ApiAuthController.java
Original file line number Diff line number Diff line change
@@ -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).
*
* <p>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<EscalatedApiAuthenticator> authenticator) {
this.authenticator = authenticator.orElse(null);
}

@PostMapping("/login")
public ResponseEntity<?> login(@RequestBody(required = false) Map<String, Object> body) {
return delegate(() -> authenticator.authenticate(body == null ? Map.of() : body));
}

@PostMapping("/register")
public ResponseEntity<?> register(@RequestBody(required = false) Map<String, Object> 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<String, Object> 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<String, Object> 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<String, Object> 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<Map<String, Object>> call) {
if (authenticator == null) {
return notConfigured();
}
try {
Map<String, Object> 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;
}
}
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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<String, Object> authenticate(Map<String, Object> params) {
throw new UnsupportedOperationException();
}

/** Register a new account. */
default Map<String, Object> register(Map<String, Object> params) {
throw new UnsupportedOperationException();
}

/** Validate a token and return the associated user. */
default Map<String, Object> validate(String token) {
throw new UnsupportedOperationException();
}

/** Exchange/refresh a token. */
default Map<String, Object> refresh(String token) {
throw new UnsupportedOperationException();
}

/** Update the authenticated user's profile. */
default Map<String, Object> updateProfile(String token, Map<String, Object> attrs) {
throw new UnsupportedOperationException();
}

/** Invalidate a token (best-effort; default no-op). */
default void logout(String token) {
// No-op by default.
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
@@ -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<String, Object> authenticate(Map<String, Object> 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<String, Object> authenticate(Map<String, Object> 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<String, Object> authenticate(Map<String, Object> 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<String, Object> 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());
}
}