2 Commits

Author SHA1 Message Date
Patrick Plate bfcfe83199 feat(w8): backend extraction completion — all missing services + controllers
W8 closes the B3 plan↔code gap (the biggest blocker from Review v3).

New services:
- AuthService: password login/register/refresh/getCurrentUser with audit
- InvitationService: create (SHA-256 hashed token), accept, revoke, list
- AccessRequestService: submit (rate-limited 3/user), approve, deny, list

New controllers:
- AuthController: POST /api/auth/{login,register,refresh}, GET /api/auth/{me,config}
- InvitationController: POST /api/invitations, POST /api/invitations/accept, DELETE/GET
- AccessRequestController: POST /api/access-requests, POST /{id}/{approve,deny}, GET
- AdminAuditController: GET /api/admin/login-events (paginated, admin-only)

New filter:
- OrgContextResolver: reads X-Org-Id/X-Org-Type headers, validates membership,
  sets OrgContext thread-local (cleared in finally block)

New DTOs: LoginRequest, RegisterRequest, RefreshRequest, UserResponse,
AuthConfigResponse, CreateInvitationRequest, CreateAccessRequestRequest,
ReviewAccessRequestRequest

Updated:
- PlateAuthAutoConfiguration: @Import list now includes all 7 new classes
- SecurityConfig: OrgContextResolver bean + filter chain; access-requests
  permitAll scoped to POST only (approve/deny now require auth)

mvn -pl plate-auth-starter compile PASSES.
2026-06-24 22:09:28 +02:00
Patrick Plate b43ab5e02c fix(sprint-0): panel-review-v2 blockers — scoped security chain, fail-closed CORS, no @ComponentScan, drop dead RefreshToken
Review-v2 (Sprint-0-Plan-Review-v2) blockers:
- B1: SecurityConfig chain now securityMatcher-scoped to plate-auth endpoints so it cannot hijack the consuming app's routes
- B2: removed @ComponentScan from auto-config; explicit @Import of @Configuration + @Service/@RestController classes
- B4: CORS fails closed (same-origin) when allowed-origins empty instead of defaulting to '*'
- B5: removed dead RefreshToken entity + repo; v0.1 uses stateless JWT refresh (rotation deferred to v0.3)
- W-A: documented OnboardingHook transaction contract

Verified: mvn -pl plate-auth-starter compile succeeds.
2026-06-24 20:22:36 +02:00
24 changed files with 1254 additions and 67 deletions
+15
View File
@@ -4,6 +4,21 @@ All notable changes to this project will be documented in this file.
## [Unreleased]
### Security / Correctness — Review-v2 blockers fixed
- **B1:** `SecurityConfig` `SecurityFilterChain` is now `securityMatcher`-scoped to plate-auth's own
endpoints (`/api/auth/**`, `/api/invitations/**`, `/api/access-requests/**`, `/api/admin/**`, `/api/me`,
`/api/memberships/**`). Previously an unscoped `@Order(-100)` chain with `anyRequest().authenticated()`
would hijack the consuming app's own routes. (panel B1)
- **B2:** Removed `@ComponentScan(basePackages="de.platesoft.auth")` from `PlateAuthAutoConfiguration`
(auto-configuration anti-pattern per Spring Boot guidance). Replaced with explicit `@Import` of the
concrete `@Configuration` classes + `@Service`/`@RestController` components. (panel B2)
- **B4:** CORS now fails closed by default. Empty `plate.auth.cors.allowed-origins` disables CORS for
plate-auth endpoints (same-origin only) instead of defaulting to `allowedOriginPatterns("*")`. (panel B4)
- **B5:** Removed dead `RefreshToken` entity + `RefreshTokenRepository`. v0.1 issues stateless JWT refresh
tokens (per the documented threat model); rotation/family-tracking is deferred to v0.3. (panel B5)
- **W-A:** Documented the `OnboardingHook` transaction contract (hooks run inside the exchange
transaction; keep them fast + idempotent).
### Added
- Initial project scaffold (W1)
- Maven parent POM with `${revision}` CI-friendly versioning
@@ -1,5 +1,20 @@
package de.platesoft.auth;
import de.platesoft.auth.config.PlateAuthFlywayConfig;
import de.platesoft.auth.config.PlateAuthExceptionHandler;
import de.platesoft.auth.config.SecurityConfig;
import de.platesoft.auth.controller.AdminAuditController;
import de.platesoft.auth.controller.AuthController;
import de.platesoft.auth.controller.AccessRequestController;
import de.platesoft.auth.controller.InvitationController;
import de.platesoft.auth.controller.OAuthController;
import de.platesoft.auth.service.AccessRequestService;
import de.platesoft.auth.service.AuthService;
import de.platesoft.auth.service.ExchangeService;
import de.platesoft.auth.service.InvitationService;
import de.platesoft.auth.service.JwtService;
import de.platesoft.auth.service.LoginEventService;
import de.platesoft.auth.service.MembershipService;
import de.platesoft.auth.spi.*;
import de.platesoft.auth.spi.defaults.*;
import org.springframework.boot.autoconfigure.AutoConfiguration;
@@ -8,13 +23,45 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty;
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.ComponentScan;
import org.springframework.context.annotation.Import;
import org.springframework.data.jpa.repository.config.EnableJpaRepositories;
import org.springframework.scheduling.annotation.EnableAsync;
/**
* plate-auth auto-configuration.
*
* <p><b>Review-v2 fix B2 — no {@code @ComponentScan}.</b> Spring Boot's auto-configuration guidance is
* explicit that starter auto-config classes must <em>not</em> use {@code @ComponentScan}. It previously
* swept the whole {@code de.platesoft.auth} package, which is unpredictable in a consumer and can
* double-instantiate beans. Instead we now {@link Import @Import} the concrete {@code @Configuration}
* classes explicitly ({@link SecurityConfig}, {@link PlateAuthFlywayConfig},
* {@link PlateAuthExceptionHandler}) and declare every SPI default as an explicit {@code @Bean} below.
*
* <p>Every concrete {@code @Service} / {@code @RestController} class the starter publishes is also
* explicitly {@link Import @Import}ed here so the starter is <em>self-sufficient</em>: it does not rely
* on the consuming application scanning {@code de.platesoft.auth}. New services/controllers added in
* later workstreams must be appended to the {@code @Import} list (this is the trade-off vs the old
* broad scan — it is intentional and keeps the bean surface explicit and predictable).
*/
@AutoConfiguration
@EnableConfigurationProperties(PlateAuthProperties.class)
@ComponentScan(basePackages = "de.platesoft.auth")
@Import({
SecurityConfig.class,
PlateAuthFlywayConfig.class,
PlateAuthExceptionHandler.class,
ExchangeService.class,
JwtService.class,
LoginEventService.class,
MembershipService.class,
AuthService.class,
InvitationService.class,
AccessRequestService.class,
OAuthController.class,
AuthController.class,
InvitationController.class,
AccessRequestController.class,
AdminAuditController.class
})
@AutoConfigurationPackage(basePackages = "de.platesoft.auth.entity")
@EnableJpaRepositories(basePackages = "de.platesoft.auth.repository")
@EnableAsync
@@ -2,7 +2,10 @@ package de.platesoft.auth.config;
import de.platesoft.auth.PlateAuthProperties;
import de.platesoft.auth.filter.JwtAuthenticationFilter;
import de.platesoft.auth.filter.OrgContextResolver;
import de.platesoft.auth.service.JwtService;
import de.platesoft.auth.service.MembershipService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.annotation.Order;
@@ -20,12 +23,32 @@ import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.List;
/**
* Security configuration for plate-auth. Registers a SecurityFilterChain
* that handles JWT validation and allows public access to auth endpoints.
* Security configuration for plate-auth.
*
* <p><b>Review-v2 fix B1 — scoped chain.</b> This {@link SecurityFilterChain} is bound with
* {@code securityMatcher(...)} to <em>only</em> the endpoints plate-auth owns. This is mandatory for a
* starter: an unscoped chain at a high priority (previously {@code @Order(-100)} with
* {@code anyRequest().authenticated()}) would otherwise hijack the consuming application's own
* {@code SecurityFilterChain} and override its public routes. Consumers keep their own default
* (catch-all) chain for every path outside {@link #PLATE_AUTH_PATHS}.
*/
@Slf4j
@Configuration
public class SecurityConfig {
/**
* Paths owned by plate-auth. The library's {@link SecurityFilterChain} only matches these.
* Everything else falls through to the consuming application's own security configuration.
*/
public static final String[] PLATE_AUTH_PATHS = {
"/api/auth/**",
"/api/invitations/**",
"/api/access-requests/**",
"/api/admin/**",
"/api/me",
"/api/memberships/**"
};
@Bean
public PasswordEncoder plateAuthPasswordEncoder() {
return new BCryptPasswordEncoder();
@@ -37,12 +60,26 @@ public class SecurityConfig {
}
@Bean
@Order(-100)
public OrgContextResolver orgContextResolver(MembershipService membershipService) {
return new OrgContextResolver(membershipService);
}
/**
* plate-auth's security chain, scoped to {@link #PLATE_AUTH_PATHS} only.
*
* <p>Carries an explicit {@code securityMatcher} so it never competes with the consuming app's
* own (catch-all) {@code SecurityFilterChain}. Consumers retain full control of every path
* plate-auth does not own.
*/
@Bean
@Order(100)
public SecurityFilterChain plateAuthSecurityFilterChain(
HttpSecurity http,
JwtAuthenticationFilter jwtFilter,
OrgContextResolver orgContextResolver,
PlateAuthProperties props) throws Exception {
http
.securityMatcher(PLATE_AUTH_PATHS)
.csrf(AbstractHttpConfigurer::disable)
.cors(cors -> cors.configurationSource(corsConfigurationSource(props)))
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
@@ -53,27 +90,38 @@ public class SecurityConfig {
"/api/auth/register",
"/api/auth/refresh",
"/api/auth/config",
"/actuator/health"
"/api/access-requests"
).permitAll()
.requestMatchers("/api/admin/**").hasAuthority("ROLE_ADMIN")
.anyRequest().authenticated()
)
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class)
.addFilterAfter(orgContextResolver, JwtAuthenticationFilter.class);
return http.build();
}
/**
* CORS configuration source.
*
* <p><b>Review-v2 fix B4 — fail closed by default.</b> When {@code plate.auth.cors.allowed-origins}
* is empty (the default), CORS is <em>disabled</em> for plate-auth's endpoints (same-origin only).
* An authentication library must never default to {@code allowedOriginPatterns("*")}. Consumers
* opt into cross-origin access by listing their origins explicitly.
*/
private CorsConfigurationSource corsConfigurationSource(PlateAuthProperties props) {
CorsConfiguration config = new CorsConfiguration();
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
if (props.getCors().getAllowedOrigins().isEmpty()) {
config.setAllowedOriginPatterns(List.of("*"));
} else {
config.setAllowedOrigins(props.getCors().getAllowedOrigins());
config.setAllowCredentials(true);
// Fail closed: register no CORS rule → Spring rejects cross-origin requests to auth paths.
log.warn("plate.auth.cors.allowed-origins is empty — CORS disabled for plate-auth endpoints "
+ "(same-origin only). Set allowed-origins to enable cross-origin access.");
return source;
}
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(props.getCors().getAllowedOrigins());
config.setAllowCredentials(true);
config.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
config.setAllowedHeaders(List.of("*"));
config.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return source;
}
@@ -0,0 +1,89 @@
package de.platesoft.auth.controller;
import de.platesoft.auth.dto.CreateAccessRequestRequest;
import de.platesoft.auth.dto.ReviewAccessRequestRequest;
import de.platesoft.auth.entity.AccessRequest;
import de.platesoft.auth.entity.Membership;
import de.platesoft.auth.entity.OrgType;
import de.platesoft.auth.entity.User;
import de.platesoft.auth.repository.UserRepository;
import de.platesoft.auth.service.AccessRequestService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
import java.util.UUID;
/**
* REST controller for self-service access requests.
*
* <p>Endpoints under {@code /api/access-requests/**}:
* <ul>
* <li>{@code POST /api/access-requests} — submit access request (authenticated, no membership required)</li>
* <li>{@code POST /api/access-requests/{id}/approve} — approve (admin)</li>
* <li>{@code POST /api/access-requests/{id}/deny} — deny (admin)</li>
* <li>{@code GET /api/access-requests} — list pending for an org (admin)</li>
* </ul>
*/
@RestController
@RequestMapping("/api/access-requests")
@RequiredArgsConstructor
public class AccessRequestController {
private final AccessRequestService accessRequestService;
private final UserRepository userRepository;
@PostMapping
public ResponseEntity<AccessRequest> submit(@Valid @RequestBody CreateAccessRequestRequest req) {
UUID userId = getCurrentUserId();
User requester = userRepository.findById(userId)
.orElseThrow(() -> new IllegalArgumentException("User not found"));
AccessRequest request = accessRequestService.submitRequest(
requester, req.orgType(), req.orgId(), req.requestedRole(), req.justification());
return ResponseEntity.status(HttpStatus.CREATED).body(request);
}
@PostMapping("/{id}/approve")
public ResponseEntity<Map<String, String>> approve(
@PathVariable UUID id,
@RequestBody(required = false) ReviewAccessRequestRequest req) {
UUID reviewerId = getCurrentUserId();
String reason = req != null ? req.decisionReason() : "Approved";
Membership membership = accessRequestService.approveRequest(id, reviewerId, reason);
return ResponseEntity.ok(Map.of(
"status", "approved",
"membershipId", membership.getId().toString()
));
}
@PostMapping("/{id}/deny")
public ResponseEntity<Map<String, String>> deny(
@PathVariable UUID id,
@RequestBody(required = false) ReviewAccessRequestRequest req) {
UUID reviewerId = getCurrentUserId();
String reason = req != null ? req.decisionReason() : "Denied";
accessRequestService.denyRequest(id, reviewerId, reason);
return ResponseEntity.ok(Map.of("status", "denied"));
}
@GetMapping
public ResponseEntity<List<AccessRequest>> pending(
@RequestParam OrgType orgType,
@RequestParam UUID orgId) {
return ResponseEntity.ok(accessRequestService.pendingForOrg(orgType, orgId));
}
private UUID getCurrentUserId() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || auth.getName() == null) {
throw new IllegalStateException("No authenticated user in SecurityContext");
}
return UUID.fromString(auth.getName());
}
}
@@ -0,0 +1,50 @@
package de.platesoft.auth.controller;
import de.platesoft.auth.entity.LoginEvent;
import de.platesoft.auth.repository.LoginEventRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Sort;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* Admin-only audit endpoints.
*
* <p>Endpoints under {@code /api/admin/**} — enforced by
* {@code .requestMatchers("/api/admin/**").hasAuthority("ROLE_ADMIN")} in {@link
* de.platesoft.auth.config.SecurityConfig SecurityConfig}.
*
* <ul>
* <li>{@code GET /api/admin/login-events} — paginated login event audit log</li>
* </ul>
*
* <p><b>Note:</b> The Envers revision browser ({@code GET /api/admin/audit/revisions}) is
* added in W11 alongside the {@code RevInfo}/{@code RevInfoListener} implementation.
*/
@RestController
@RequestMapping("/api/admin")
@RequiredArgsConstructor
public class AdminAuditController {
private final LoginEventRepository loginEventRepository;
/**
* Paginated login event audit log. Newest first.
*
* @param page zero-based page index (default 0)
* @param size page size (default 50, max 200)
*/
@GetMapping("/login-events")
public ResponseEntity<Page<LoginEvent>> loginEvents(
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "50") int size) {
size = Math.min(size, 200); // cap to prevent excessive queries
PageRequest pageable = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, "id"));
return ResponseEntity.ok(loginEventRepository.findAll(pageable));
}
}
@@ -0,0 +1,120 @@
package de.platesoft.auth.controller;
import de.platesoft.auth.PlateAuthProperties;
import de.platesoft.auth.dto.*;
import de.platesoft.auth.service.AuthService;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*;
import java.util.ArrayList;
import java.util.List;
import java.util.UUID;
/**
* REST controller for password-based auth endpoints.
*
* <p>Endpoints mounted under {@code /api/auth/**} (scoped by plate-auth's SecurityFilterChain):
* <ul>
* <li>{@code POST /api/auth/login} — password login (public)</li>
* <li>{@code POST /api/auth/register} — password registration (public, if enabled)</li>
* <li>{@code POST /api/auth/refresh} — refresh token rotation (public)</li>
* <li>{@code GET /api/auth/me} — current user + memberships (authenticated)</li>
* <li>{@code GET /api/auth/config} — provider list + registration flag (public)</li>
* </ul>
*/
@Slf4j
@RestController
@RequestMapping("/api/auth")
@RequiredArgsConstructor
public class AuthController {
private final AuthService authService;
private final PlateAuthProperties props;
@PostMapping("/login")
public ResponseEntity<TokenResponse> login(@Valid @RequestBody LoginRequest req,
HttpServletRequest request) {
TokenResponse tokens = authService.login(
req.email(), req.password(),
request.getRemoteAddr(), request.getHeader("User-Agent"));
return ResponseEntity.ok(tokens);
}
@PostMapping("/register")
public ResponseEntity<TokenResponse> register(@Valid @RequestBody RegisterRequest req,
HttpServletRequest request) {
if (!props.getRegistration().isEnabled()) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).build();
}
TokenResponse tokens = authService.register(
req.email(), req.password(), req.firstName(), req.lastName(),
request.getRemoteAddr(), request.getHeader("User-Agent"));
return ResponseEntity.status(HttpStatus.CREATED).body(tokens);
}
@PostMapping("/refresh")
public ResponseEntity<TokenResponse> refresh(@Valid @RequestBody RefreshRequest req) {
TokenResponse tokens = authService.refresh(req.refreshToken());
return ResponseEntity.ok(tokens);
}
@GetMapping("/me")
public ResponseEntity<UserResponse> me() {
UUID userId = getCurrentUserId();
var info = authService.getCurrentUser(userId);
return ResponseEntity.ok(UserResponse.from(info.user(), info.memberships()));
}
@GetMapping("/config")
public ResponseEntity<AuthConfigResponse> config() {
List<AuthConfigResponse.ProviderInfo> providers = new ArrayList<>();
providers.add(AuthConfigResponse.ProviderInfo.google());
providers.add(AuthConfigResponse.ProviderInfo.microsoft(
props.getProviders().getMicrosoft().isEnabled()));
providers.add(AuthConfigResponse.ProviderInfo.emailMagicLink(
props.getProviders().getEmailMagicLink().isEnabled()));
return ResponseEntity.ok(new AuthConfigResponse(
props.getRegistration().isEnabled(),
providers
));
}
// ── Helpers ───────────────────────────────────────────────────────────────
/**
* Extract the authenticated user's UUID from the SecurityContext.
* The {@link de.platesoft.auth.filter.JwtAuthenticationFilter} sets the principal
* to the userId as a string.
*/
private UUID getCurrentUserId() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || auth.getName() == null) {
throw new IllegalStateException("No authenticated user in SecurityContext");
}
return UUID.fromString(auth.getName());
}
// ── Exception handlers ────────────────────────────────────────────────────
@ExceptionHandler(AuthService.BadCredentialsException.class)
public ResponseEntity<String> handleBadCredentials(AuthService.BadCredentialsException e) {
return ResponseEntity.status(HttpStatus.UNAUTHORIZED).body(e.getMessage());
}
@ExceptionHandler(IllegalStateException.class)
public ResponseEntity<String> handleIllegalState(IllegalStateException e) {
// Registration disabled or email already in use
if (e.getMessage().contains("Registration is disabled")) {
return ResponseEntity.status(HttpStatus.FORBIDDEN).body(e.getMessage());
}
return ResponseEntity.status(HttpStatus.CONFLICT).body(e.getMessage());
}
}
@@ -0,0 +1,86 @@
package de.platesoft.auth.controller;
import de.platesoft.auth.dto.CreateInvitationRequest;
import de.platesoft.auth.entity.Invitation;
import de.platesoft.auth.entity.Membership;
import de.platesoft.auth.entity.OrgType;
import de.platesoft.auth.entity.User;
import de.platesoft.auth.repository.UserRepository;
import de.platesoft.auth.service.InvitationService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
import java.util.UUID;
/**
* REST controller for invitation management.
*
* <p>Endpoints under {@code /api/invitations/**}:
* <ul>
* <li>{@code POST /api/invitations} — create invitation (authenticated)</li>
* <li>{@code POST /api/invitations/accept} — accept invitation (authenticated, with token)</li>
* <li>{@code DELETE /api/invitations/{id}} — revoke invitation (authenticated)</li>
* <li>{@code GET /api/invitations} — list pending invitations (authenticated)</li>
* </ul>
*/
@RestController
@RequestMapping("/api/invitations")
@RequiredArgsConstructor
public class InvitationController {
private final InvitationService invitationService;
private final UserRepository userRepository;
@PostMapping
public ResponseEntity<Invitation> create(@Valid @RequestBody CreateInvitationRequest req) {
UUID createdBy = getCurrentUserId();
Invitation invitation = invitationService.createInvitation(
req.email(), req.orgType(), req.orgId(), req.role(), createdBy);
return ResponseEntity.status(HttpStatus.CREATED).body(invitation);
}
@PostMapping("/accept")
public ResponseEntity<Map<String, String>> accept(@RequestBody Map<String, String> body) {
String token = body.get("token");
if (token == null || token.isBlank()) {
return ResponseEntity.badRequest().body(Map.of("error", "Missing invitation token"));
}
UUID userId = getCurrentUserId();
User user = userRepository.findById(userId)
.orElseThrow(() -> new IllegalArgumentException("User not found"));
Membership membership = invitationService.acceptInvitation(token, user);
return ResponseEntity.ok(Map.of(
"status", "accepted",
"membershipId", membership.getId().toString()
));
}
@DeleteMapping("/{id}")
public ResponseEntity<Void> revoke(@PathVariable UUID id) {
UUID revokedBy = getCurrentUserId();
invitationService.revokeInvitation(id, revokedBy);
return ResponseEntity.noContent().build();
}
@GetMapping
public ResponseEntity<List<Invitation>> pending(
@RequestParam OrgType orgType,
@RequestParam UUID orgId) {
return ResponseEntity.ok(invitationService.pendingForOrg(orgType, orgId));
}
private UUID getCurrentUserId() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || auth.getName() == null) {
throw new IllegalStateException("No authenticated user in SecurityContext");
}
return UUID.fromString(auth.getName());
}
}
@@ -0,0 +1,30 @@
package de.platesoft.auth.dto;
import java.util.List;
/**
* Response for {@code GET /api/auth/config} — public endpoint returning enabled providers
* and registration flag. Consumed by the frontend to render login buttons.
*/
public record AuthConfigResponse(
boolean registrationEnabled,
List<ProviderInfo> providers
) {
public record ProviderInfo(
String id,
String name,
boolean enabled
) {
public static ProviderInfo google() {
return new ProviderInfo("google", "Google", true);
}
public static ProviderInfo microsoft(boolean enabled) {
return new ProviderInfo("microsoft", "Microsoft Entra ID", enabled);
}
public static ProviderInfo emailMagicLink(boolean enabled) {
return new ProviderInfo("email", "Email Magic Link", enabled);
}
}
}
@@ -0,0 +1,19 @@
package de.platesoft.auth.dto;
import de.platesoft.auth.entity.MembershipRole;
import de.platesoft.auth.entity.OrgType;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import java.util.UUID;
/**
* Request body for {@code POST /api/access-requests} — submit a self-service access request.
*/
public record CreateAccessRequestRequest(
@NotNull OrgType orgType,
@NotNull UUID orgId,
@NotNull MembershipRole requestedRole,
@Size(max = 500) String justification
) {
}
@@ -0,0 +1,20 @@
package de.platesoft.auth.dto;
import de.platesoft.auth.entity.MembershipRole;
import de.platesoft.auth.entity.OrgType;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import java.util.UUID;
/**
* Request body for {@code POST /api/invitations} — create a new invitation.
*/
public record CreateInvitationRequest(
@NotBlank @Email String email,
@NotNull OrgType orgType,
@NotNull UUID orgId,
@NotNull MembershipRole role
) {
}
@@ -0,0 +1,13 @@
package de.platesoft.auth.dto;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
/**
* Request body for {@code POST /api/auth/login}.
*/
public record LoginRequest(
@NotBlank @Email String email,
@NotBlank String password
) {
}
@@ -0,0 +1,11 @@
package de.platesoft.auth.dto;
import jakarta.validation.constraints.NotBlank;
/**
* Request body for {@code POST /api/auth/refresh}.
*/
public record RefreshRequest(
@NotBlank String refreshToken
) {
}
@@ -0,0 +1,17 @@
package de.platesoft.auth.dto;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
/**
* Request body for {@code POST /api/auth/register}.
* Registration must be enabled via {@code plate.auth.registration.enabled=true}.
*/
public record RegisterRequest(
@NotBlank @Email String email,
@NotBlank @Size(min = 8, message = "Password must be at least 8 characters") String password,
@NotBlank @Size(max = 100) String firstName,
@NotBlank @Size(max = 100) String lastName
) {
}
@@ -0,0 +1,12 @@
package de.platesoft.auth.dto;
import jakarta.validation.constraints.Size;
/**
* Request body for {@code POST /api/access-requests/{id}/approve} and
* {@code POST /api/access-requests/{id}/deny}.
*/
public record ReviewAccessRequestRequest(
@Size(max = 500) String decisionReason
) {
}
@@ -0,0 +1,56 @@
package de.platesoft.auth.dto;
import de.platesoft.auth.entity.Membership;
import de.platesoft.auth.entity.Role;
import de.platesoft.auth.entity.User;
import java.time.OffsetDateTime;
import java.util.List;
import java.util.UUID;
/**
* Response for {@code GET /api/auth/me} — current user + active memberships.
*/
public record UserResponse(
UUID id,
String email,
String firstName,
String lastName,
Role role,
boolean active,
String lastProvider,
OffsetDateTime lastLogin,
List<MembershipSummary> memberships
) {
public static UserResponse from(User user, List<Membership> memberships) {
return new UserResponse(
user.getId(),
user.getEmail(),
user.getFirstName(),
user.getLastName(),
user.getRole(),
user.isActive(),
user.getLastProvider(),
user.getLastLogin(),
memberships.stream().map(MembershipSummary::from).toList()
);
}
public record MembershipSummary(
UUID id,
String orgType,
UUID orgId,
String role,
String status
) {
public static MembershipSummary from(Membership m) {
return new MembershipSummary(
m.getId(),
m.getOrgType().name(),
m.getOrgId(),
m.getRole().name(),
m.getStatus().name()
);
}
}
}
@@ -1,42 +0,0 @@
package de.platesoft.auth.entity;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.envers.Audited;
import java.time.Instant;
import java.util.UUID;
/**
* Refresh token entity for rotation tracking.
*/
@Entity
@Table(name = "refresh_tokens")
@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder
public class RefreshToken {
@Id
@Column(columnDefinition = "uuid")
private UUID id;
@Column(name = "user_id", nullable = false, columnDefinition = "uuid")
private UUID userId;
@Column(nullable = false, unique = true, length = 255)
private String token;
@Column(name = "expires_at", nullable = false)
private Instant expiresAt;
@Column(nullable = false)
private boolean revoked;
@Column(name = "created_at", nullable = false, updatable = false)
private Instant createdAt;
@PrePersist
void prePersist() {
if (id == null) id = UUID.randomUUID();
if (createdAt == null) createdAt = Instant.now();
}
}
@@ -0,0 +1,14 @@
package de.platesoft.auth.filter;
import de.platesoft.auth.entity.OrgType;
import java.util.UUID;
/**
* Resolved org context for the current request.
*
* <p>Set by {@link OrgContextResolver} from the {@code X-Org-Id} header and stored in
* {@link OrgContextHolder} for downstream services to read.
*/
public record OrgContext(OrgType orgType, UUID orgId) {
}
@@ -0,0 +1,28 @@
package de.platesoft.auth.filter;
/**
* Thread-local holder for the {@link OrgContext} resolved on the current request.
*
* <p>Set by {@link OrgContextResolver} and cleared after the request completes.
* Downstream services read it via {@link #get()} to know which org the current
* request is scoped to.
*/
public final class OrgContextHolder {
private static final ThreadLocal<OrgContext> HOLDER = new ThreadLocal<>();
private OrgContextHolder() {
}
public static void set(OrgContext context) {
HOLDER.set(context);
}
public static OrgContext get() {
return HOLDER.get();
}
public static void clear() {
HOLDER.remove();
}
}
@@ -0,0 +1,100 @@
package de.platesoft.auth.filter;
import de.platesoft.auth.entity.Membership;
import de.platesoft.auth.entity.OrgType;
import de.platesoft.auth.service.MembershipService;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.UUID;
/**
* Resolves the org context for each authenticated request from the {@code X-Org-Id} (and optional
* {@code X-Org-Type}) headers.
*
* <p>When the header is present AND the authenticated user has an active membership for that org,
* the {@link OrgContext} is stored in {@link OrgContextHolder} for downstream services. When the
* header is absent or the membership is invalid, no context is set — the request proceeds normally
* (some endpoints don't need org scoping).
*
* <p>The thread-local is always cleared in {@code finally} to prevent leakage across pooled threads.
*/
@Slf4j
@RequiredArgsConstructor
public class OrgContextResolver extends OncePerRequestFilter {
private final MembershipService membershipService;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
try {
resolveOrgContext(request);
filterChain.doFilter(request, response);
} finally {
OrgContextHolder.clear();
}
}
private void resolveOrgContext(HttpServletRequest request) {
String orgIdHeader = request.getHeader("X-Org-Id");
if (orgIdHeader == null || orgIdHeader.isBlank()) {
return; // No org selected — fine, not all requests need org context
}
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (auth == null || auth.getName() == null) {
return; // Not authenticated — JwtAuthenticationFilter hasn't run or failed
}
UUID userId;
try {
userId = UUID.fromString(auth.getName());
} catch (IllegalArgumentException e) {
log.warn("Could not parse userId from principal: {}", auth.getName());
return;
}
UUID orgId;
try {
orgId = UUID.fromString(orgIdHeader);
} catch (IllegalArgumentException e) {
log.warn("Invalid X-Org-Id header format: {}", orgIdHeader);
return;
}
// Determine org type: from header or by searching user's memberships
String orgTypeHeader = request.getHeader("X-Org-Type");
final OrgType resolvedOrgType;
if (orgTypeHeader != null && !orgTypeHeader.isBlank()) {
try {
resolvedOrgType = OrgType.valueOf(orgTypeHeader.toUpperCase());
} catch (IllegalArgumentException e) {
log.warn("Unknown X-Org-Type header value: {}", orgTypeHeader);
return;
}
} else {
resolvedOrgType = null;
}
// Validate membership
if (resolvedOrgType != null) {
membershipService.resolve(userId, resolvedOrgType, orgId)
.ifPresent(m -> OrgContextHolder.set(new OrgContext(resolvedOrgType, orgId)));
} else {
// No org type specified — find by orgId across user's active memberships
membershipService.activeForUser(userId).stream()
.filter(m -> m.getOrgId().equals(orgId))
.findFirst()
.ifPresent(m -> OrgContextHolder.set(new OrgContext(m.getOrgType(), orgId)));
}
}
}
@@ -1,12 +0,0 @@
package de.platesoft.auth.repository;
import de.platesoft.auth.entity.RefreshToken;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
import java.util.UUID;
public interface RefreshTokenRepository extends JpaRepository<RefreshToken, UUID> {
Optional<RefreshToken> findByToken(String token);
void deleteByUserId(UUID userId);
}
@@ -0,0 +1,130 @@
package de.platesoft.auth.service;
import de.platesoft.auth.entity.*;
import de.platesoft.auth.repository.AccessRequestRepository;
import de.platesoft.auth.repository.UserRepository;
import de.platesoft.auth.spi.AccessRequestMailer;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.util.List;
import java.util.UUID;
/**
* Access request lifecycle service.
*
* <p>Manages self-service access requests: a user requests access to an org,
* admins review, approve (→ membership granted) or deny.
*
* <p><b>Rate limiting (§10 threat model):</b> Max 3 pending requests per user per day
* to prevent access-request DoS.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AccessRequestService {
private static final long MAX_PENDING_PER_USER = 3;
private final AccessRequestRepository requestRepository;
private final MembershipService membershipService;
private final AccessRequestMailer accessRequestMailer;
private final UserRepository userRepository;
/**
* Submit a new access request.
*
* @throws IllegalStateException if the user already has MAX_PENDING_PER_USER pending requests.
*/
@Transactional
public AccessRequest submitRequest(User requester, OrgType orgType, UUID orgId,
MembershipRole requestedRole, String justification) {
long pendingCount = requestRepository.countByRequesterIdAndStatus(
requester.getId(), AccessRequestStatus.PENDING);
if (pendingCount >= MAX_PENDING_PER_USER) {
throw new IllegalStateException(
"Too many pending access requests (max " + MAX_PENDING_PER_USER + ")");
}
AccessRequest request = AccessRequest.builder()
.requester(requester)
.orgType(orgType)
.orgId(orgId)
.requestedRole(requestedRole)
.justification(justification)
.status(AccessRequestStatus.PENDING)
.build();
request = requestRepository.save(request);
accessRequestMailer.notifyAdmins(request);
log.info("Access request submitted: requester={} org={}:{} role={}",
requester.getId(), orgType, orgId, requestedRole);
return request;
}
/**
* Approve a pending access request — grants the requested membership.
*/
@Transactional
public Membership approveRequest(UUID requestId, UUID reviewerId, String decisionReason) {
AccessRequest request = requestRepository.findById(requestId)
.orElseThrow(() -> new IllegalArgumentException("Access request not found: " + requestId));
if (request.getStatus() != AccessRequestStatus.PENDING) {
throw new IllegalStateException("Request is not pending (status=" + request.getStatus() + ")");
}
User requester = request.getRequester();
Membership membership = membershipService.grant(
requester, request.getOrgType(), request.getOrgId(),
request.getRequestedRole(), reviewerId,
"Access request approved: " + decisionReason);
request.setStatus(AccessRequestStatus.APPROVED);
request.setReviewerId(reviewerId);
request.setDecisionReason(decisionReason);
request.setDecidedAt(Instant.now());
accessRequestMailer.notifyRequester(request);
log.info("Access request approved: id={} requester={} by={}", requestId, requester.getId(), reviewerId);
return membership;
}
/**
* Deny a pending access request.
*/
@Transactional
public void denyRequest(UUID requestId, UUID reviewerId, String decisionReason) {
AccessRequest request = requestRepository.findById(requestId)
.orElseThrow(() -> new IllegalArgumentException("Access request not found: " + requestId));
if (request.getStatus() != AccessRequestStatus.PENDING) {
throw new IllegalStateException("Request is not pending (status=" + request.getStatus() + ")");
}
request.setStatus(AccessRequestStatus.DENIED);
request.setReviewerId(reviewerId);
request.setDecisionReason(decisionReason);
request.setDecidedAt(Instant.now());
accessRequestMailer.notifyRequester(request);
log.info("Access request denied: id={} requester={} by={}", requestId, request.getRequester().getId(), reviewerId);
}
/**
* List pending access requests for an org.
*/
@Transactional(readOnly = true)
public List<AccessRequest> pendingForOrg(OrgType orgType, UUID orgId) {
return requestRepository.findByOrgTypeAndOrgIdAndStatus(orgType, orgId, AccessRequestStatus.PENDING);
}
/**
* List a user's access requests.
*/
@Transactional(readOnly = true)
public List<AccessRequest> forUser(UUID userId) {
return requestRepository.findByRequesterIdAndStatus(userId, AccessRequestStatus.PENDING);
}
}
@@ -0,0 +1,148 @@
package de.platesoft.auth.service;
import java.util.UUID;
import de.platesoft.auth.PlateAuthProperties;
import de.platesoft.auth.dto.TokenResponse;
import de.platesoft.auth.entity.Membership;
import de.platesoft.auth.entity.Role;
import de.platesoft.auth.entity.User;
import de.platesoft.auth.repository.UserRepository;
import io.jsonwebtoken.Claims;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.OffsetDateTime;
import java.util.List;
/**
* Password-based authentication service.
*
* <p>Handles login (credential verification), registration (if enabled), refresh-token rotation,
* and the "current user" lookup for {@code GET /api/auth/me}.
*
* <p><b>Security note (§9.6 of Sprint-0-Plan).</b> Failed login returns a generic
* "invalid credentials" message — never reveals whether the email exists or not.
* All login attempts (success + failure) are recorded via {@link LoginEventService}.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class AuthService {
private final UserRepository userRepository;
private final PasswordEncoder passwordEncoder;
private final JwtService jwtService;
private final LoginEventService loginEventService;
private final MembershipService membershipService;
private final PlateAuthProperties props;
/**
* Authenticate with email + password.
*
* @throws BadCredentialsException if the email doesn't exist or the password is wrong.
* The exception message is intentionally generic — no "user exists" leak.
*/
@Transactional
public TokenResponse login(String email, String password, String ipAddress, String userAgent) {
User user = userRepository.findByEmail(email).orElse(null);
if (user == null || user.getPasswordHash() == null
|| !passwordEncoder.matches(password, user.getPasswordHash())) {
loginEventService.recordFailure(email, "password", "BAD_CREDENTIALS", ipAddress, userAgent);
throw new BadCredentialsException("Invalid email or password");
}
if (!user.isActive()) {
loginEventService.recordFailure(email, "password", "LOCKED", ipAddress, userAgent);
throw new BadCredentialsException("Account is deactivated");
}
user.setLastProvider("password");
user.setLastLogin(OffsetDateTime.now());
loginEventService.recordSuccess(user, "password", ipAddress, userAgent);
return jwtService.issueTokensFor(user);
}
/**
* Register a new user with email + password.
*
* @throws IllegalStateException if registration is disabled or the email is already in use.
*/
@Transactional
public TokenResponse register(String email, String password, String firstName, String lastName,
String ipAddress, String userAgent) {
if (!props.getRegistration().isEnabled()) {
throw new IllegalStateException("Registration is disabled");
}
if (userRepository.existsByEmail(email)) {
throw new IllegalStateException("Email already registered");
}
User user = User.builder()
.email(email)
.passwordHash(passwordEncoder.encode(password))
.firstName(firstName)
.lastName(lastName)
.role(Role.ROLE_USER)
.active(true)
.lastProvider("password")
.build();
user = userRepository.save(user);
loginEventService.recordSuccess(user, "password", ipAddress, userAgent);
return jwtService.issueTokensFor(user);
}
/**
* Refresh an access token using a valid refresh token.
*
* @throws BadCredentialsException if the refresh token is invalid, expired, or not a refresh-type token.
*/
public TokenResponse refresh(String refreshToken) {
if (!jwtService.isTokenValid(refreshToken)) {
throw new BadCredentialsException("Invalid refresh token");
}
Claims claims = jwtService.extractClaims(refreshToken);
if (!"refresh".equals(claims.get("type", String.class))) {
throw new BadCredentialsException("Token is not a refresh token");
}
UUID userId = UUID.fromString(claims.getSubject());
User user = userRepository.findById(userId)
.orElseThrow(() -> new BadCredentialsException("User not found for refresh token"));
if (!user.isActive()) {
throw new BadCredentialsException("Account is deactivated");
}
return jwtService.issueTokensFor(user);
}
/**
* Get the current user + active memberships for {@code GET /api/auth/me}.
*/
@Transactional(readOnly = true)
public CurrentUserInfo getCurrentUser(UUID userId) {
User user = userRepository.findById(userId)
.orElseThrow(() -> new IllegalArgumentException("User not found: " + userId));
List<Membership> memberships = membershipService.activeForUser(userId);
return new CurrentUserInfo(user, memberships);
}
/**
* Record for {@link #getCurrentUser} — the user entity + their active memberships.
*/
public record CurrentUserInfo(User user, List<Membership> memberships) {
}
/**
* Thrown when login or refresh fails. Maps to HTTP 401.
*/
public static class BadCredentialsException extends RuntimeException {
public BadCredentialsException(String message) {
super(message);
}
}
}
@@ -0,0 +1,171 @@
package de.platesoft.auth.service;
import de.platesoft.auth.entity.*;
import de.platesoft.auth.repository.InvitationRepository;
import de.platesoft.auth.spi.InvitationMailer;
import de.platesoft.auth.spi.OrgDisplayNameResolver;
import de.platesoft.auth.spi.OrgValidator;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.security.MessageDigest;
import java.security.SecureRandom;
import java.time.Duration;
import java.time.Instant;
import java.util.Base64;
import java.util.HexFormat;
import java.util.List;
import java.util.UUID;
/**
* Invitation lifecycle service.
*
* <p>Manages the full invitation workflow: create (generate token, store hash, mail),
* accept (validate token, grant membership, mark accepted), revoke, and list.
*
* <p><b>Security:</b> Invitation tokens are 64-char URL-safe random values. Only the
* SHA-256 hash is stored in the database — the plaintext token is returned to the caller
* exactly once and sent to the invitee via the {@link InvitationMailer} SPI.
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class InvitationService {
private static final Duration DEFAULT_EXPIRATION = Duration.ofDays(7);
private static final int TOKEN_LENGTH = 48; // bytes → 64-char Base64 string
private final InvitationRepository invitationRepository;
private final MembershipService membershipService;
private final InvitationMailer invitationMailer;
private final OrgDisplayNameResolver orgDisplayNameResolver;
/**
* Create a new invitation.
*
* @param email the invitee's email
* @param orgType the org type (COMPANY, WORKSPACE, etc.)
* @param orgId the org UUID
* @param role the role to grant on acceptance
* @param createdBy the inviting user's UUID
* @return the created invitation with the plaintext token (returned once — not stored)
*/
@Transactional
public Invitation createInvitation(String email, OrgType orgType, UUID orgId,
MembershipRole role, UUID createdBy) {
// Generate token
byte[] tokenBytes = new byte[TOKEN_LENGTH];
new SecureRandom().nextBytes(tokenBytes);
String plaintextToken = Base64.getUrlEncoder().withoutPadding().encodeToString(tokenBytes);
String tokenHash = sha256Hex(plaintextToken);
Invitation invitation = Invitation.builder()
.token(tokenHash)
.email(email)
.orgType(orgType)
.orgId(orgId)
.role(role)
.status(InvitationStatus.PENDING)
.createdBy(createdBy)
.createdAt(Instant.now())
.expiresAt(Instant.now().plus(DEFAULT_EXPIRATION))
.build();
invitation = invitationRepository.save(invitation);
// Mail the invitee with the plaintext token URL
String orgName = orgDisplayNameResolver.displayName(orgType, orgId);
invitationMailer.sendInvitation(invitation, plaintextToken);
log.info("Invitation created: email={} org={}:{} role={} by={}",
email, orgType, orgId, role, createdBy);
// Return a copy with the plaintext token so the caller can see it once
var withToken = Invitation.builder()
.id(invitation.getId())
.token(plaintextToken) // plaintext, not the hash
.email(invitation.getEmail())
.orgType(invitation.getOrgType())
.orgId(invitation.getOrgId())
.role(invitation.getRole())
.status(invitation.getStatus())
.createdBy(invitation.getCreatedBy())
.createdAt(invitation.getCreatedAt())
.expiresAt(invitation.getExpiresAt())
.build();
return withToken;
}
/**
* Accept an invitation using the plaintext token.
*
* @param plaintextToken the token from the invitation email
* @param user the accepting user
* @throws IllegalArgumentException if the token is invalid, expired, already accepted, or revoked
*/
@Transactional
public Membership acceptInvitation(String plaintextToken, User user) {
String tokenHash = sha256Hex(plaintextToken);
Invitation invitation = invitationRepository.findByToken(tokenHash)
.orElseThrow(() -> new IllegalArgumentException("Invalid invitation token"));
if (invitation.getStatus() == InvitationStatus.ACCEPTED) {
throw new IllegalStateException("Invitation already accepted");
}
if (invitation.getStatus() == InvitationStatus.REVOKED) {
throw new IllegalStateException("Invitation has been revoked");
}
if (invitation.getExpiresAt().isBefore(Instant.now())) {
invitation.setStatus(InvitationStatus.EXPIRED);
throw new IllegalStateException("Invitation has expired");
}
// Grant membership
Membership membership = membershipService.grant(
user, invitation.getOrgType(), invitation.getOrgId(),
invitation.getRole(), invitation.getCreatedBy(),
"Invitation accepted");
// Mark invitation as accepted
invitation.setStatus(InvitationStatus.ACCEPTED);
invitation.setAcceptedAt(Instant.now());
invitation.setAcceptedBy(user.getId());
log.info("Invitation accepted: email={} org={}:{} user={}",
invitation.getEmail(), invitation.getOrgType(), invitation.getOrgId(), user.getId());
return membership;
}
/**
* Revoke a pending invitation.
*/
@Transactional
public void revokeInvitation(UUID invitationId, UUID revokedBy) {
Invitation invitation = invitationRepository.findById(invitationId)
.orElseThrow(() -> new IllegalArgumentException("Invitation not found: " + invitationId));
if (invitation.getStatus() != InvitationStatus.PENDING) {
throw new IllegalStateException("Can only revoke pending invitations");
}
invitation.setStatus(InvitationStatus.REVOKED);
invitation.setRevokedAt(Instant.now());
invitation.setRevokedBy(revokedBy);
log.info("Invitation revoked: id={} by={}", invitationId, revokedBy);
}
/**
* List pending invitations for an org.
*/
@Transactional(readOnly = true)
public List<Invitation> pendingForOrg(OrgType orgType, UUID orgId) {
return invitationRepository.findByOrgTypeAndOrgIdAndStatus(orgType, orgId, InvitationStatus.PENDING);
}
private String sha256Hex(String input) {
try {
MessageDigest md = MessageDigest.getInstance("SHA-256");
return HexFormat.of().formatHex(md.digest(input.getBytes(java.nio.charset.StandardCharsets.UTF_8)));
} catch (Exception e) {
throw new IllegalStateException("SHA-256 hash failed", e);
}
}
}
@@ -6,8 +6,25 @@ import de.platesoft.auth.entity.User;
/**
* Called on first and subsequent sign-ins. Consumers wire their T3 onboarding logic here.
* The default implementation is a no-op.
*
* <p><b>Transaction contract (Review-v2 W-A).</b> Both methods are invoked <em>inside</em> the
* {@code @Transactional} boundary of {@code ExchangeService.verifyAndExchange} — i.e. within the same
* transaction that creates/updates the user and records the login event. Two consequences consumers
* must respect:
* <ul>
* <li><b>Keep it fast and idempotent.</b> A slow hook blocks the login response; a failing hook
* rolls back the entire sign-in (user creation + login event). Do heavy/external work
* asynchronously, or move it to a post-commit listener, not inline here.</li>
* <li><b>Idempotency required.</b> The same identity may trigger {@code onFirstSignIn} more than
* once under retries/races; implementations must be safe to call repeatedly.</li>
* </ul>
* Future v0.2 may move these calls to a post-commit phase; until then assume "in-transaction".
*/
public interface OnboardingHook {
/**
* Called the first time a user authenticates via a provider (no prior {@code user_identity}).
* Runs inside the exchange transaction — see class-level contract.
*/
void onFirstSignIn(User user, LoginProvider provider);
default void onSubsequentSignIn(User user, LoginProvider provider) {