feat(w2): auth core entities + Google OAuth + JWT + NextAuth bridge

Extracted from InspectFlow Sprint 14.1-14.2, repackaged to de.platesoft.auth.*:
- Entities: User, UserIdentity, Membership, Invitation, AccessRequest, LoginEvent, RefreshToken
- Enums: Role, OrgType, MembershipRole, MembershipStatus, InvitationStatus, AccessRequestStatus, LoginProvider
- Services: JwtService, ExchangeService, MembershipService, LoginEventService
- Filter: JwtAuthenticationFilter
- Controller: OAuthController (POST /api/auth/exchange)
- Config: PlateAuthAutoConfiguration, PlateAuthProperties (plate.auth.* namespace)
- Repositories: all auth-related JPA repositories
- SPI: OrgValidator, OrgDisplayNameResolver, InvitationMailer, AccessRequestMailer, OnboardingHook
- SPI defaults: PermissiveOrgValidator (WARN per call), LoggingInvitationMailer,
  LoggingAccessRequestMailer, DefaultOrgDisplayNameResolver, NoOpOnboardingHook
- DTOs: ExchangePayload, TokenResponse
- Security: BCrypt encoder, stateless session, CORS from PlateAuthProperties
- META-INF/spring AutoConfiguration.imports registered

All @Value refs replaced with PlateAuthProperties injection.
No references to de.platesoft.inspectflow.* remain.
This commit is contained in:
Patrick Plate
2026-06-24 15:46:54 +02:00
parent 973c82f304
commit 63c953d9b9
43 changed files with 1423 additions and 0 deletions
+4
View File
@@ -41,6 +41,10 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-mail</artifactId>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
<!-- Hibernate Envers (Audit) -->
<dependency>
@@ -0,0 +1,117 @@
package de.platesoft.auth;
import de.platesoft.auth.filter.JwtAuthenticationFilter;
import de.platesoft.auth.service.JwtService;
import de.platesoft.auth.spi.*;
import de.platesoft.auth.spi.defaults.*;
import org.springframework.boot.autoconfigure.AutoConfiguration;
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.data.jpa.repository.config.EnableJpaRepositories;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.web.cors.CorsConfiguration;
import org.springframework.web.cors.CorsConfigurationSource;
import org.springframework.web.cors.UrlBasedCorsConfigurationSource;
import java.util.List;
@AutoConfiguration
@EnableConfigurationProperties(PlateAuthProperties.class)
@ComponentScan(basePackages = "de.platesoft.auth")
@EnableJpaRepositories(basePackages = "de.platesoft.auth.repository")
@EnableAsync
@ConditionalOnProperty(prefix = "plate.auth", name = "enabled", havingValue = "true", matchIfMissing = true)
public class PlateAuthAutoConfiguration {
// ── SPI defaults ─────────────────────────────────────────────────────────
@Bean
@ConditionalOnMissingBean(OrgValidator.class)
public OrgValidator orgValidator() {
return new PermissiveOrgValidator();
}
@Bean
@ConditionalOnMissingBean(OrgDisplayNameResolver.class)
public OrgDisplayNameResolver orgDisplayNameResolver() {
return new DefaultOrgDisplayNameResolver();
}
@Bean
@ConditionalOnMissingBean(InvitationMailer.class)
public InvitationMailer invitationMailer() {
return new LoggingInvitationMailer();
}
@Bean
@ConditionalOnMissingBean(AccessRequestMailer.class)
public AccessRequestMailer accessRequestMailer() {
return new LoggingAccessRequestMailer();
}
@Bean
@ConditionalOnMissingBean(OnboardingHook.class)
public OnboardingHook onboardingHook() {
return new NoOpOnboardingHook();
}
// ── Security ─────────────────────────────────────────────────────────────
@Bean
@ConditionalOnMissingBean(PasswordEncoder.class)
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}
@Bean
public JwtAuthenticationFilter jwtAuthenticationFilter(JwtService jwtService) {
return new JwtAuthenticationFilter(jwtService);
}
@Bean
public SecurityFilterChain plateAuthSecurityFilterChain(
HttpSecurity http,
JwtAuthenticationFilter jwtFilter,
PlateAuthProperties props) throws Exception {
http
.csrf(AbstractHttpConfigurer::disable)
.cors(cors -> cors.configurationSource(corsConfigurationSource(props)))
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.requestMatchers(
"/api/auth/exchange",
"/api/auth/login",
"/api/auth/register",
"/api/auth/refresh",
"/api/auth/config",
"/actuator/health"
).permitAll()
.requestMatchers("/api/admin/**").hasAuthority("ROLE_ADMIN")
.anyRequest().authenticated()
)
.addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
private CorsConfigurationSource corsConfigurationSource(PlateAuthProperties props) {
CorsConfiguration config = new CorsConfiguration();
config.setAllowedOrigins(props.getCors().getAllowedOrigins());
config.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"));
config.setAllowedHeaders(List.of("*"));
config.setAllowCredentials(true);
config.setMaxAge(3600L);
UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource();
source.registerCorsConfiguration("/**", config);
return source;
}
}
@@ -0,0 +1,73 @@
package de.platesoft.auth;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.validation.annotation.Validated;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
@ConfigurationProperties(prefix = "plate.auth")
@Validated
@Data
public class PlateAuthProperties {
private Jwt jwt = new Jwt();
private Exchange exchange = new Exchange();
private Registration registration = new Registration();
private Cors cors = new Cors();
private Providers providers = new Providers();
@Data
public static class Jwt {
@NotBlank
@Size(min = 32, message = "JWT secret must be at least 32 characters")
private String secret;
private Duration accessExpiration = Duration.ofMinutes(15);
private Duration refreshExpiration = Duration.ofDays(30);
private String issuer = "plate-auth";
}
@Data
public static class Exchange {
@NotBlank
@Size(min = 32, message = "Exchange secret must be at least 32 characters")
private String secret;
private Duration maxAge = Duration.ofSeconds(60);
private Duration nonceTtl = Duration.ofMinutes(5);
}
@Data
public static class Registration {
private boolean enabled = false;
}
@Data
public static class Cors {
private List<String> allowedOrigins = new ArrayList<>();
private List<String> additionalPermitPaths = new ArrayList<>();
}
@Data
public static class Providers {
private ProviderToggle google = new ProviderToggle(true);
private ProviderToggle microsoft = new ProviderToggle(false);
private ProviderToggle emailMagicLink = new ProviderToggle(false);
}
@Data
public static class ProviderToggle {
private boolean enabled;
public ProviderToggle() {
this.enabled = false;
}
public ProviderToggle(boolean enabled) {
this.enabled = enabled;
}
}
}
@@ -0,0 +1,27 @@
package de.platesoft.auth.controller;
import de.platesoft.auth.dto.TokenResponse;
import de.platesoft.auth.service.ExchangeService;
import jakarta.servlet.http.HttpServletRequest;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
@RestController
@RequestMapping("/api/auth/exchange")
@RequiredArgsConstructor
public class OAuthController {
private final ExchangeService exchangeService;
@PostMapping
public ResponseEntity<TokenResponse> exchange(
@RequestBody String body,
@RequestHeader(value = "X-Exchange-Signature", required = false) String signature,
HttpServletRequest request) {
String ip = request.getRemoteAddr();
String ua = request.getHeader("User-Agent");
TokenResponse tokens = exchangeService.verifyAndExchange(body, signature, ip, ua);
return ResponseEntity.ok(tokens);
}
}
@@ -0,0 +1,17 @@
package de.platesoft.auth.dto;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
/**
* Payload of the HMAC-signed exchange envelope from NextAuth signIn callback.
*/
@JsonIgnoreProperties(ignoreUnknown = true)
public record ExchangePayload(
String provider,
String providerSubject,
String email,
String name,
String inviteToken,
String nonce,
long iat
) {}
@@ -0,0 +1,7 @@
package de.platesoft.auth.dto;
public record TokenResponse(
String accessToken,
String refreshToken,
long expiresIn
) {}
@@ -0,0 +1,64 @@
package de.platesoft.auth.entity;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.envers.Audited;
import java.time.Instant;
import java.util.UUID;
@Entity
@Table(name = "access_requests")
@Audited
@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder
public class AccessRequest {
@Id
@Column(columnDefinition = "uuid")
private UUID id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "requester_id", nullable = false)
private User requester;
@Enumerated(EnumType.STRING)
@Column(name = "org_type", nullable = false, length = 16)
private OrgType orgType;
@Column(name = "org_id", nullable = false, columnDefinition = "uuid")
private UUID orgId;
@Enumerated(EnumType.STRING)
@Column(name = "requested_role", nullable = false, length = 16)
private MembershipRole requestedRole;
@Column(length = 500)
private String justification;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 16)
private AccessRequestStatus status;
@Column(name = "reviewer_id", columnDefinition = "uuid")
private UUID reviewerId;
@Column(name = "decision_reason", length = 500)
private String decisionReason;
@Column(name = "created_at", nullable = false, updatable = false)
private Instant createdAt;
@Column(name = "decided_at")
private Instant decidedAt;
@Version
private Long version;
@PrePersist
void prePersist() {
if (id == null) id = UUID.randomUUID();
if (createdAt == null) createdAt = Instant.now();
if (status == null) status = AccessRequestStatus.PENDING;
if (requestedRole == null) requestedRole = MembershipRole.VIEWER;
}
}
@@ -0,0 +1,3 @@
package de.platesoft.auth.entity;
public enum AccessRequestStatus { PENDING, APPROVED, DENIED, EXPIRED }
@@ -0,0 +1,74 @@
package de.platesoft.auth.entity;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.envers.Audited;
import java.time.Instant;
import java.util.UUID;
@Entity
@Table(name = "invitations")
@Audited
@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder
public class Invitation {
@Id
@Column(columnDefinition = "uuid")
private UUID id;
@Column(nullable = false, unique = true, length = 64)
private String token;
@Column(nullable = false, length = 255)
private String email;
@Enumerated(EnumType.STRING)
@Column(name = "org_type", nullable = false, length = 16)
private OrgType orgType;
@Column(name = "org_id", nullable = false, columnDefinition = "uuid")
private UUID orgId;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 16)
private MembershipRole role;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 16)
private InvitationStatus status;
@Column(name = "created_by", nullable = false, columnDefinition = "uuid")
private UUID createdBy;
@Column(name = "created_at", nullable = false, updatable = false)
private Instant createdAt;
@Column(name = "expires_at", nullable = false)
private Instant expiresAt;
@Column(name = "accepted_at")
private Instant acceptedAt;
@Column(name = "accepted_by", columnDefinition = "uuid")
private UUID acceptedBy;
@Column(name = "revoked_at")
private Instant revokedAt;
@Column(name = "revoked_by", columnDefinition = "uuid")
private UUID revokedBy;
@Column(length = 500)
private String note;
@Version
private Long version;
@PrePersist
void prePersist() {
if (id == null) id = UUID.randomUUID();
if (createdAt == null) createdAt = Instant.now();
if (status == null) status = InvitationStatus.PENDING;
}
}
@@ -0,0 +1,3 @@
package de.platesoft.auth.entity;
public enum InvitationStatus { PENDING, ACCEPTED, REVOKED, EXPIRED }
@@ -0,0 +1,46 @@
package de.platesoft.auth.entity;
import jakarta.persistence.*;
import lombok.*;
import java.time.Instant;
import java.util.UUID;
@Entity
@Table(name = "login_events")
@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder
public class LoginEvent {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(name = "user_id", columnDefinition = "uuid")
private UUID userId;
@Column(nullable = false, length = 255)
private String email;
@Column(nullable = false, length = 32)
private String provider;
@Column(nullable = false, length = 32)
private String outcome;
@Column(name = "ip_address", length = 45)
private String ipAddress;
@Column(name = "user_agent", length = 512)
private String userAgent;
@Column(name = "correlation_id", length = 64)
private String correlationId;
@Column(name = "occurred_at", nullable = false)
private Instant occurredAt;
@PrePersist
void prePersist() {
if (occurredAt == null) occurredAt = Instant.now();
}
}
@@ -0,0 +1,3 @@
package de.platesoft.auth.entity;
public enum LoginProvider { GOOGLE, MICROSOFT, EMAIL, PASSWORD }
@@ -0,0 +1,64 @@
package de.platesoft.auth.entity;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.envers.Audited;
import java.time.Instant;
import java.util.UUID;
@Entity
@Table(name = "memberships", uniqueConstraints = {
@UniqueConstraint(name = "uq_memberships_user_org", columnNames = {"user_id", "org_type", "org_id"})
})
@Audited
@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder
public class Membership {
@Id @Column(columnDefinition = "uuid")
private UUID id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "user_id", nullable = false)
private User user;
@Enumerated(EnumType.STRING)
@Column(name = "org_type", nullable = false, length = 16)
private OrgType orgType;
@Column(name = "org_id", nullable = false, columnDefinition = "uuid")
private UUID orgId;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 16)
private MembershipRole role;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 16)
private MembershipStatus status;
@Column(name = "granted_by", columnDefinition = "uuid")
private UUID grantedBy;
@Column(name = "grant_reason", length = 64)
private String grantReason;
@Column(name = "created_at", nullable = false, updatable = false)
private Instant createdAt;
@Column(name = "revoked_at")
private Instant revokedAt;
@Column(name = "revoked_by", columnDefinition = "uuid")
private UUID revokedBy;
@Version
private Long version;
@PrePersist
void prePersist() {
if (id == null) id = UUID.randomUUID();
if (createdAt == null) createdAt = Instant.now();
if (status == null) status = MembershipStatus.ACTIVE;
}
}
@@ -0,0 +1,9 @@
package de.platesoft.auth.entity;
public enum MembershipRole {
OWNER,
ADMIN,
INSPECTOR,
VIEWER,
MEMBER
}
@@ -0,0 +1,3 @@
package de.platesoft.auth.entity;
public enum MembershipStatus { ACTIVE, REVOKED, PENDING }
@@ -0,0 +1,3 @@
package de.platesoft.auth.entity;
public enum OrgType { COMPANY, CLUB, WORKSPACE }
@@ -0,0 +1,42 @@
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,9 @@
package de.platesoft.auth.entity;
/**
* Global user roles. Consumers may extend via MembershipRole for per-org roles.
*/
public enum Role {
ROLE_USER,
ROLE_ADMIN
}
@@ -0,0 +1,78 @@
package de.platesoft.auth.entity;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.envers.Audited;
import org.hibernate.envers.NotAudited;
import java.time.OffsetDateTime;
import java.util.UUID;
@Entity
@Table(name = "users")
@Audited
@Getter
@Setter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class User {
@Id
@Column(nullable = false, updatable = false)
private UUID id;
@Column(nullable = false, unique = true)
private String email;
@NotAudited
@Column(name = "password_hash")
private String passwordHash;
@Column(name = "first_name", nullable = false, length = 100)
private String firstName;
@Column(name = "last_name", nullable = false, length = 100)
private String lastName;
@Enumerated(EnumType.STRING)
@Column(nullable = false, length = 50)
private Role role;
@Column(nullable = false)
private boolean active;
@Column(name = "default_org_id")
private UUID defaultOrgId;
@Column(name = "last_provider", length = 32)
private String lastProvider;
@Version
@NotAudited
private Long version;
@Column(name = "last_login")
private OffsetDateTime lastLogin;
@NotAudited
@Column(name = "created_at", nullable = false, updatable = false)
private OffsetDateTime createdAt;
@NotAudited
@Column(name = "updated_at", nullable = false)
private OffsetDateTime updatedAt;
@PrePersist
protected void onCreate() {
if (id == null) id = UUID.randomUUID();
createdAt = OffsetDateTime.now();
updatedAt = OffsetDateTime.now();
if (!active) active = true;
}
@PreUpdate
protected void onUpdate() {
updatedAt = OffsetDateTime.now();
}
}
@@ -0,0 +1,55 @@
package de.platesoft.auth.entity;
import jakarta.persistence.*;
import lombok.*;
import org.hibernate.envers.Audited;
import java.time.Instant;
import java.util.UUID;
/**
* Provider-agnostic identity link: one row per (provider, subject) pair, many per user.
*/
@Entity
@Table(name = "user_identities", uniqueConstraints = {
@UniqueConstraint(name = "uq_user_identities_provider_subject", columnNames = {"provider", "subject"})
})
@Audited
@Getter @Setter @NoArgsConstructor @AllArgsConstructor @Builder
public class UserIdentity {
@Id
@Column(columnDefinition = "uuid")
private UUID id;
@ManyToOne(fetch = FetchType.LAZY, optional = false)
@JoinColumn(name = "user_id", nullable = false)
private User user;
@Column(nullable = false, length = 32)
private String provider;
@Column(nullable = false, length = 255)
private String subject;
@Column(nullable = false, length = 255)
private String email;
@Column(name = "tenant_id", length = 64)
private String tenantId;
@Column(name = "linked_at", nullable = false)
private Instant linkedAt;
@Column(name = "last_login_at")
private Instant lastLoginAt;
@Version
private Long version;
@PrePersist
void prePersist() {
if (id == null) id = UUID.randomUUID();
if (linkedAt == null) linkedAt = Instant.now();
}
}
@@ -0,0 +1,47 @@
package de.platesoft.auth.filter;
import de.platesoft.auth.service.JwtService;
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.authentication.UsernamePasswordAuthenticationToken;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
import java.util.List;
import java.util.UUID;
@Slf4j
@RequiredArgsConstructor
public class JwtAuthenticationFilter extends OncePerRequestFilter {
private final JwtService jwtService;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
FilterChain filterChain) throws ServletException, IOException {
String authHeader = request.getHeader("Authorization");
if (authHeader != null && authHeader.startsWith("Bearer ")) {
String token = authHeader.substring(7);
if (jwtService.isTokenValid(token)) {
UUID userId = jwtService.extractUserId(token);
String email = jwtService.extractEmail(token);
String role = jwtService.extractRole(token);
var auth = new UsernamePasswordAuthenticationToken(
userId.toString(),
null,
List.of(new SimpleGrantedAuthority(role))
);
auth.setDetails(email);
SecurityContextHolder.getContext().setAuthentication(auth);
}
}
filterChain.doFilter(request, response);
}
}
@@ -0,0 +1,15 @@
package de.platesoft.auth.repository;
import de.platesoft.auth.entity.AccessRequest;
import de.platesoft.auth.entity.AccessRequestStatus;
import de.platesoft.auth.entity.OrgType;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.UUID;
public interface AccessRequestRepository extends JpaRepository<AccessRequest, UUID> {
List<AccessRequest> findByOrgTypeAndOrgIdAndStatus(OrgType orgType, UUID orgId, AccessRequestStatus status);
List<AccessRequest> findByRequesterIdAndStatus(UUID requesterId, AccessRequestStatus status);
long countByRequesterIdAndStatus(UUID requesterId, AccessRequestStatus status);
}
@@ -0,0 +1,16 @@
package de.platesoft.auth.repository;
import de.platesoft.auth.entity.Invitation;
import de.platesoft.auth.entity.InvitationStatus;
import de.platesoft.auth.entity.OrgType;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
public interface InvitationRepository extends JpaRepository<Invitation, UUID> {
Optional<Invitation> findByToken(String token);
List<Invitation> findByOrgTypeAndOrgIdAndStatus(OrgType orgType, UUID orgId, InvitationStatus status);
List<Invitation> findByEmailAndStatus(String email, InvitationStatus status);
}
@@ -0,0 +1,7 @@
package de.platesoft.auth.repository;
import de.platesoft.auth.entity.LoginEvent;
import org.springframework.data.jpa.repository.JpaRepository;
public interface LoginEventRepository extends JpaRepository<LoginEvent, Long> {
}
@@ -0,0 +1,16 @@
package de.platesoft.auth.repository;
import de.platesoft.auth.entity.Membership;
import de.platesoft.auth.entity.MembershipStatus;
import de.platesoft.auth.entity.OrgType;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
public interface MembershipRepository extends JpaRepository<Membership, UUID> {
Optional<Membership> findByUserIdAndOrgTypeAndOrgId(UUID userId, OrgType orgType, UUID orgId);
List<Membership> findByUserIdAndStatus(UUID userId, MembershipStatus status);
List<Membership> findByOrgTypeAndOrgIdAndStatus(OrgType orgType, UUID orgId, MembershipStatus status);
}
@@ -0,0 +1,12 @@
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,11 @@
package de.platesoft.auth.repository;
import de.platesoft.auth.entity.UserIdentity;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
import java.util.UUID;
public interface UserIdentityRepository extends JpaRepository<UserIdentity, UUID> {
Optional<UserIdentity> findByProviderAndSubject(String provider, String subject);
}
@@ -0,0 +1,12 @@
package de.platesoft.auth.repository;
import de.platesoft.auth.entity.User;
import org.springframework.data.jpa.repository.JpaRepository;
import java.util.Optional;
import java.util.UUID;
public interface UserRepository extends JpaRepository<User, UUID> {
Optional<User> findByEmail(String email);
boolean existsByEmail(String email);
}
@@ -0,0 +1,162 @@
package de.platesoft.auth.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import de.platesoft.auth.PlateAuthProperties;
import de.platesoft.auth.dto.ExchangePayload;
import de.platesoft.auth.dto.TokenResponse;
import de.platesoft.auth.entity.*;
import de.platesoft.auth.repository.UserIdentityRepository;
import de.platesoft.auth.repository.UserRepository;
import de.platesoft.auth.spi.OnboardingHook;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import javax.crypto.Mac;
import javax.crypto.spec.SecretKeySpec;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.time.Instant;
import java.util.HexFormat;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ConcurrentMap;
/**
* Verifies the HMAC-SHA256 exchange envelope from NextAuth and provides replay protection.
* The nonce store is in-memory and single-instance only (see Architecture.md § 5 note).
*/
@Slf4j
@Service
public class ExchangeService {
private final String secret;
private final long maxAgeSeconds;
private final long nonceTtlSeconds;
private final ObjectMapper mapper;
private final JwtService jwtService;
private final UserRepository userRepository;
private final UserIdentityRepository identityRepository;
private final OnboardingHook onboardingHook;
private final LoginEventService loginEventService;
private final ConcurrentMap<String, Long> seenNonces = new ConcurrentHashMap<>();
public ExchangeService(
PlateAuthProperties props,
ObjectMapper mapper,
JwtService jwtService,
UserRepository userRepository,
UserIdentityRepository identityRepository,
OnboardingHook onboardingHook,
LoginEventService loginEventService) {
this.secret = props.getExchange().getSecret();
this.maxAgeSeconds = props.getExchange().getMaxAge().getSeconds();
this.nonceTtlSeconds = props.getExchange().getNonceTtl().getSeconds();
this.mapper = mapper;
this.jwtService = jwtService;
this.userRepository = userRepository;
this.identityRepository = identityRepository;
this.onboardingHook = onboardingHook;
this.loginEventService = loginEventService;
}
/**
* Verify HMAC signature, check envelope age and nonce uniqueness,
* then find-or-create user and issue tokens.
*/
@Transactional
public TokenResponse verifyAndExchange(String body, String signatureHex, String ipAddress, String userAgent) {
if (signatureHex == null || signatureHex.isBlank()) {
throw new SecurityException("Missing X-Exchange-Signature");
}
String expected = hmacSha256Hex(body);
if (!constantTimeEquals(expected, signatureHex)) {
log.warn("Exchange HMAC mismatch — possible tampering or key mismatch");
throw new SecurityException("Invalid exchange signature");
}
ExchangePayload payload;
try {
payload = mapper.readValue(body, ExchangePayload.class);
} catch (Exception e) {
throw new SecurityException("Malformed exchange payload");
}
long now = Instant.now().getEpochSecond();
if (Math.abs(now - payload.iat()) > maxAgeSeconds) {
throw new SecurityException("Exchange envelope expired");
}
Long prev = seenNonces.putIfAbsent(payload.nonce(), now);
if (prev != null) {
throw new SecurityException("Replayed nonce");
}
gcNonces(now);
// Find or create user + identity
User user = findOrCreateUser(payload);
// Record login event
loginEventService.recordSuccess(user, payload.provider(), ipAddress, userAgent);
return jwtService.issueTokensFor(user);
}
private User findOrCreateUser(ExchangePayload payload) {
var existingIdentity = identityRepository.findByProviderAndSubject(
payload.provider(), payload.providerSubject());
if (existingIdentity.isPresent()) {
UserIdentity identity = existingIdentity.get();
identity.setLastLoginAt(Instant.now());
User user = identity.getUser();
user.setLastProvider(payload.provider());
onboardingHook.onSubsequentSignIn(user, LoginProvider.valueOf(payload.provider().toUpperCase()));
return user;
}
// New user
User user = userRepository.findByEmail(payload.email())
.orElseGet(() -> userRepository.save(User.builder()
.email(payload.email())
.firstName(payload.name() != null ? payload.name().split(" ")[0] : "")
.lastName(payload.name() != null && payload.name().contains(" ")
? payload.name().substring(payload.name().indexOf(' ') + 1) : "")
.role(Role.ROLE_USER)
.active(true)
.lastProvider(payload.provider())
.build()));
identityRepository.save(UserIdentity.builder()
.user(user)
.provider(payload.provider())
.subject(payload.providerSubject())
.email(payload.email())
.build());
onboardingHook.onFirstSignIn(user, LoginProvider.valueOf(payload.provider().toUpperCase()));
return user;
}
private String hmacSha256Hex(String body) {
try {
Mac mac = Mac.getInstance("HmacSHA256");
mac.init(new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256"));
return HexFormat.of().formatHex(mac.doFinal(body.getBytes(StandardCharsets.UTF_8)));
} catch (Exception e) {
throw new IllegalStateException("HMAC computation failed", e);
}
}
/** Constant-time comparison using MessageDigest.isEqual */
private boolean constantTimeEquals(String a, String b) {
byte[] aBytes = a.getBytes(StandardCharsets.UTF_8);
byte[] bBytes = b.getBytes(StandardCharsets.UTF_8);
return MessageDigest.isEqual(aBytes, bBytes);
}
private void gcNonces(long now) {
seenNonces.entrySet().removeIf(e -> now - e.getValue() > nonceTtlSeconds);
}
}
@@ -0,0 +1,96 @@
package de.platesoft.auth.service;
import de.platesoft.auth.PlateAuthProperties;
import de.platesoft.auth.dto.TokenResponse;
import de.platesoft.auth.entity.User;
import io.jsonwebtoken.*;
import io.jsonwebtoken.security.Keys;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import javax.crypto.SecretKey;
import java.nio.charset.StandardCharsets;
import java.util.Date;
import java.util.Map;
import java.util.UUID;
@Slf4j
@Service
public class JwtService {
private final SecretKey key;
private final long accessExpirationMs;
private final long refreshExpirationMs;
private final String issuer;
public JwtService(PlateAuthProperties props) {
this.key = Keys.hmacShaKeyFor(props.getJwt().getSecret().getBytes(StandardCharsets.UTF_8));
this.accessExpirationMs = props.getJwt().getAccessExpiration().toMillis();
this.refreshExpirationMs = props.getJwt().getRefreshExpiration().toMillis();
this.issuer = props.getJwt().getIssuer();
}
public String generateAccessToken(UUID userId, String email, String role) {
return Jwts.builder()
.issuer(issuer)
.subject(userId.toString())
.claims(Map.of("email", email, "role", role))
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + accessExpirationMs))
.signWith(key)
.compact();
}
public String generateRefreshToken(UUID userId) {
return Jwts.builder()
.issuer(issuer)
.subject(userId.toString())
.claim("type", "refresh")
.id(UUID.randomUUID().toString())
.issuedAt(new Date())
.expiration(new Date(System.currentTimeMillis() + refreshExpirationMs))
.signWith(key)
.compact();
}
public TokenResponse issueTokensFor(User user) {
String access = generateAccessToken(user.getId(), user.getEmail(), user.getRole().name());
String refresh = generateRefreshToken(user.getId());
return new TokenResponse(access, refresh, accessExpirationMs / 1000);
}
public long getRefreshExpirationMs() {
return refreshExpirationMs;
}
public Claims extractClaims(String token) {
return Jwts.parser()
.verifyWith(key)
.requireIssuer(issuer)
.build()
.parseSignedClaims(token)
.getPayload();
}
public UUID extractUserId(String token) {
return UUID.fromString(extractClaims(token).getSubject());
}
public String extractEmail(String token) {
return extractClaims(token).get("email", String.class);
}
public String extractRole(String token) {
return extractClaims(token).get("role", String.class);
}
public boolean isTokenValid(String token) {
try {
extractClaims(token);
return true;
} catch (JwtException | IllegalArgumentException e) {
log.debug("Token validation failed: {}", e.getMessage());
return false;
}
}
}
@@ -0,0 +1,42 @@
package de.platesoft.auth.service;
import de.platesoft.auth.entity.LoginEvent;
import de.platesoft.auth.entity.User;
import de.platesoft.auth.repository.LoginEventRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import java.util.UUID;
@Slf4j
@Service
@RequiredArgsConstructor
public class LoginEventService {
private final LoginEventRepository repo;
@Async
public void recordSuccess(User user, String provider, String ipAddress, String userAgent) {
repo.save(LoginEvent.builder()
.userId(user.getId())
.email(user.getEmail())
.provider(provider)
.outcome("SUCCESS")
.ipAddress(ipAddress)
.userAgent(userAgent)
.build());
}
@Async
public void recordFailure(String email, String provider, String outcome, String ipAddress, String userAgent) {
repo.save(LoginEvent.builder()
.email(email)
.provider(provider)
.outcome(outcome)
.ipAddress(ipAddress)
.userAgent(userAgent)
.build());
}
}
@@ -0,0 +1,116 @@
package de.platesoft.auth.service;
import de.platesoft.auth.entity.*;
import de.platesoft.auth.repository.MembershipRepository;
import de.platesoft.auth.spi.OrgValidator;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.time.Instant;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
@Slf4j
@Service
@RequiredArgsConstructor
public class MembershipService {
private final MembershipRepository repo;
private final OrgValidator orgValidator;
@Transactional
public Membership grant(User user, OrgType orgType, UUID orgId, MembershipRole role,
UUID grantedBy, String reason) {
// Validate org exists via SPI
if (!orgValidator.exists(orgType, orgId)) {
throw new IllegalArgumentException(
"OrgValidator rejected org: " + orgType + "/" + orgId);
}
Optional<Membership> existing = repo.findByUserIdAndOrgTypeAndOrgId(user.getId(), orgType, orgId);
if (existing.isPresent()) {
Membership m = existing.get();
if (m.getStatus() == MembershipStatus.ACTIVE) {
if (rank(role) > rank(m.getRole())) {
log.info("Upgrading membership user={} org={} {}→{}", user.getId(), orgId, m.getRole(), role);
m.setRole(role);
}
return m;
}
m.setStatus(MembershipStatus.ACTIVE);
m.setRole(role);
m.setRevokedAt(null);
m.setRevokedBy(null);
m.setGrantedBy(grantedBy);
m.setGrantReason(reason);
return m;
}
return repo.save(Membership.builder()
.user(user)
.orgType(orgType)
.orgId(orgId)
.role(role)
.status(MembershipStatus.ACTIVE)
.grantedBy(grantedBy)
.grantReason(reason)
.build());
}
@Transactional
public void revoke(UUID membershipId, UUID revokedBy) {
Membership m = repo.findById(membershipId)
.orElseThrow(() -> new IllegalArgumentException("Membership not found: " + membershipId));
if (m.getStatus() == MembershipStatus.REVOKED) return;
m.setStatus(MembershipStatus.REVOKED);
m.setRevokedAt(Instant.now());
m.setRevokedBy(revokedBy);
}
@Transactional(readOnly = true)
public List<Membership> activeForUser(UUID userId) {
return repo.findByUserIdAndStatus(userId, MembershipStatus.ACTIVE);
}
@Transactional(readOnly = true)
public Optional<Membership> resolve(UUID userId, OrgType orgType, UUID orgId) {
return repo.findByUserIdAndOrgTypeAndOrgId(userId, orgType, orgId)
.filter(m -> m.getStatus() == MembershipStatus.ACTIVE);
}
@Transactional(readOnly = true)
public boolean isOrgAdmin(UUID userId, OrgType orgType, UUID orgId) {
return repo.findByUserIdAndOrgTypeAndOrgId(userId, orgType, orgId)
.filter(m -> m.getStatus() == MembershipStatus.ACTIVE)
.map(m -> rank(m.getRole()) >= rank(MembershipRole.ADMIN))
.orElse(false);
}
public void assertAdminOf(UUID userId, OrgType orgType, UUID orgId) {
if (!isOrgAdmin(userId, orgType, orgId)) {
throw new AccessDeniedException(
"User " + userId + " is not admin of " + orgType + "/" + orgId);
}
}
@Transactional(readOnly = true)
public List<Membership> adminsByOrg(OrgType orgType, UUID orgId) {
return repo.findByOrgTypeAndOrgIdAndStatus(orgType, orgId, MembershipStatus.ACTIVE)
.stream()
.filter(m -> rank(m.getRole()) >= rank(MembershipRole.ADMIN))
.toList();
}
private int rank(MembershipRole r) {
return switch (r) {
case OWNER -> 5;
case ADMIN -> 4;
case INSPECTOR -> 3;
case VIEWER -> 2;
case MEMBER -> 1;
};
}
}
@@ -0,0 +1,12 @@
package de.platesoft.auth.spi;
import de.platesoft.auth.entity.AccessRequest;
/**
* Notifies admins of new access requests and requesters of decisions.
* The default implementation logs notifications at INFO level.
*/
public interface AccessRequestMailer {
void notifyAdmins(AccessRequest request);
void notifyRequester(AccessRequest request);
}
@@ -0,0 +1,10 @@
package de.platesoft.auth.spi;
import de.platesoft.auth.entity.Invitation;
/**
* Sends invitation emails. The default implementation logs the accept URL at INFO level.
*/
public interface InvitationMailer {
void sendInvitation(Invitation invitation, String acceptUrl);
}
@@ -0,0 +1,16 @@
package de.platesoft.auth.spi;
import de.platesoft.auth.entity.LoginProvider;
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.
*/
public interface OnboardingHook {
void onFirstSignIn(User user, LoginProvider provider);
default void onSubsequentSignIn(User user, LoginProvider provider) {
// no-op by default
}
}
@@ -0,0 +1,13 @@
package de.platesoft.auth.spi;
import de.platesoft.auth.entity.OrgType;
import java.util.UUID;
/**
* Resolves a human-readable display name for an organization.
* Used in invitation emails, access request notifications, etc.
*/
public interface OrgDisplayNameResolver {
String displayName(OrgType type, UUID orgId);
}
@@ -0,0 +1,19 @@
package de.platesoft.auth.spi;
import de.platesoft.auth.entity.OrgType;
import java.util.UUID;
/**
* Validates that a given (org_type, org_id) pair represents a real organization
* in the consuming application's domain.
*
* <p>plate-auth calls this SPI whenever a membership is being granted or an invitation
* is being created. If this returns {@code false}, the operation is rejected.</p>
*
* <p>The default implementation ({@code PermissiveOrgValidator}) always returns {@code true}
* and logs a WARN on every call. Override this bean in production.</p>
*/
public interface OrgValidator {
boolean exists(OrgType type, UUID orgId);
}
@@ -0,0 +1,17 @@
package de.platesoft.auth.spi.defaults;
import de.platesoft.auth.entity.OrgType;
import de.platesoft.auth.spi.OrgDisplayNameResolver;
import java.util.UUID;
/**
* Default display name resolver — returns type:orgId.
*/
public class DefaultOrgDisplayNameResolver implements OrgDisplayNameResolver {
@Override
public String displayName(OrgType type, UUID orgId) {
return type + ":" + orgId.toString();
}
}
@@ -0,0 +1,25 @@
package de.platesoft.auth.spi.defaults;
import de.platesoft.auth.entity.AccessRequest;
import de.platesoft.auth.spi.AccessRequestMailer;
import lombok.extern.slf4j.Slf4j;
/**
* Default AccessRequestMailer — logs notifications at INFO level.
*/
@Slf4j
public class LoggingAccessRequestMailer implements AccessRequestMailer {
@Override
public void notifyAdmins(AccessRequest request) {
log.info("[plate-auth] Access request from user {} for {}/{} with role {}",
request.getRequester().getEmail(), request.getOrgType(),
request.getOrgId(), request.getRequestedRole());
}
@Override
public void notifyRequester(AccessRequest request) {
log.info("[plate-auth] Access request {} decided: {}",
request.getId(), request.getStatus());
}
}
@@ -0,0 +1,19 @@
package de.platesoft.auth.spi.defaults;
import de.platesoft.auth.entity.Invitation;
import de.platesoft.auth.spi.InvitationMailer;
import lombok.extern.slf4j.Slf4j;
/**
* Default InvitationMailer — logs the accept URL at INFO level.
*/
@Slf4j
public class LoggingInvitationMailer implements InvitationMailer {
@Override
public void sendInvitation(Invitation invitation, String acceptUrl) {
log.info("[plate-auth] Invitation for {} to join {}/{} with role {}. Accept URL: {}",
invitation.getEmail(), invitation.getOrgType(), invitation.getOrgId(),
invitation.getRole(), acceptUrl);
}
}
@@ -0,0 +1,16 @@
package de.platesoft.auth.spi.defaults;
import de.platesoft.auth.entity.LoginProvider;
import de.platesoft.auth.entity.User;
import de.platesoft.auth.spi.OnboardingHook;
/**
* Default OnboardingHook — no-op.
*/
public class NoOpOnboardingHook implements OnboardingHook {
@Override
public void onFirstSignIn(User user, LoginProvider provider) {
// no-op — consumers override to wire their onboarding logic
}
}
@@ -0,0 +1,22 @@
package de.platesoft.auth.spi.defaults;
import de.platesoft.auth.entity.OrgType;
import de.platesoft.auth.spi.OrgValidator;
import lombok.extern.slf4j.Slf4j;
import java.util.UUID;
/**
* Default OrgValidator that accepts all (org_type, org_id) pairs.
* Logs a WARN on every call to make it impossible to miss in production.
* Override this bean to implement real validation.
*/
@Slf4j
public class PermissiveOrgValidator implements OrgValidator {
@Override
public boolean exists(OrgType type, UUID orgId) {
log.warn("OrgValidator default permissive — override de.platesoft.auth.spi.OrgValidator bean before production. Called with ({}, {})", type, orgId);
return true;
}
}
@@ -0,0 +1 @@
de.platesoft.auth.PlateAuthAutoConfiguration