diff --git a/minerva-api/CLAUDE.md b/minerva-api/CLAUDE.md
index ca5b1a1c..ceb20bb0 100644
--- a/minerva-api/CLAUDE.md
+++ b/minerva-api/CLAUDE.md
@@ -53,6 +53,25 @@ SPRING_PROFILES_ACTIVE=local mvn spring-boot:run -pl minerva-api -am
SPRING_PROFILES_ACTIVE=local mvn spring-boot:run
```
+### Run without Cognito / AWS credentials (no-auth mode)
+
+The `no-auth` profile disables JWT validation and injects a hardcoded dev user. No Cognito or
+AWS Secrets Manager required — all secret values default to placeholder strings.
+
+```bash
+SPRING_PROFILES_ACTIVE=no-auth mvn spring-boot:run -pl minerva-api -am
+```
+
+You can also stack it on top of `local` to keep real secrets while skipping auth:
+
+```bash
+SPRING_PROFILES_ACTIVE=local,no-auth mvn spring-boot:run -pl minerva-api -am
+```
+
+**Warning:** Never activate `no-auth` in `test` or `prod`. All authenticated API endpoints will
+accept requests without a Bearer token and return data for the hardcoded dev user
+(`sub: 00000000-0000-0000-0000-000000000dev`, name: "Dev User", FREE subscription tier).
+
Server starts on `http://0.0.0.0:8080`.
### Build
@@ -160,6 +179,9 @@ Everything else requires a valid Cognito JWT in `Authorization: Bearer
@@ -66,48 +67,58 @@ @Component @RequestScope public class CurrentUser { - + + /** Stable sub used for the hardcoded dev user when {@code auth.disabled=true}. */ + static final String DEV_USER_SUB = "00000000-0000-0000-0000-000000000dev"; + private final UserRepository userRepository; - + private final CognitoIdentityProviderClient cognitoClient; - + + /** Whether authentication is disabled (no-auth profile). */ + private final boolean authDisabled; + /** * The Cognito sub (subject identifier) extracted from the JWT token, used for user lookup and creation. This is the * unique identifier assigned by AWS Cognito to the authenticated user and is used as the primary key for linking * Cognito users to local User entities. */ private final String cognitoSub; - + /** * The application's local User entity representing the authenticated user. Contains application-specific data such * as profile information and preferences. */ private User user; - + /** * The AWS Cognito user information for the authenticated user. Contains authentication and identity data such as * email, phone number, and verification status. */ - @Value("${secrets.cognito-user-pool-id}") + @Value("${secrets.cognito-user-pool-id:}") @Getter(AccessLevel.NONE) private String userPoolId; - + /** * The Cognito user information from AWS Cognito. Contains authentication and identity data such as email, phone * number, and verification status. */ private CognitoUser cognitoUser; - + /** * A unified DTO representation combining both local User and CognitoUser data. This provides a complete view of the * user's information in a single object. */ private UserDto userDto; - + /** * Initializes the CurrentUser component by finding or creating a user based on the Cognito sub. *
- * This constructor performs the following operations: + * When {@code auth.disabled=true} (no-auth profile), a stable hardcoded dev-user sub is used + * and no JWT is required in the security context. + *
+ *+ * In normal mode this constructor performs the following operations: *
- * If a user with the given Cognito sub doesn't exist in the local database, a new user - * record is automatically created. This enables seamless onboarding of authenticated users. - *
* * @param userRepository the repository for accessing user data in the local database * @param cognitoClient the AWS Cognito client for retrieving user information from Cognito + * @param authDisabled {@code true} when the {@code no-auth} profile is active * @throws UnauthorizedException if no authenticated user is found in the security context or if the Cognito user - * cannot be retrieved + * cannot be retrieved (normal mode only) * @throws UserNotFoundException if the user cannot be found or created in the local database */ @SuppressFBWarnings( @@ -134,16 +142,23 @@ public class CurrentUser { + "partially-constructed state via finalise()." ) public CurrentUser( - UserRepository userRepository, CognitoIdentityProviderClient cognitoClient + UserRepository userRepository, + CognitoIdentityProviderClient cognitoClient, + @Value("${auth.disabled:false}") boolean authDisabled ) { this.userRepository = userRepository; this.cognitoClient = cognitoClient; - this.cognitoSub = getCognitoSubFromSecurityContext(); - if (this.cognitoSub.isEmpty()) { - throw new UnauthorizedException("Authenticated user does not have a valid Cognito sub."); + this.authDisabled = authDisabled; + if (authDisabled) { + this.cognitoSub = DEV_USER_SUB; + } else { + this.cognitoSub = getCognitoSubFromSecurityContext(); + if (this.cognitoSub.isEmpty()) { + throw new UnauthorizedException("Authenticated user does not have a valid Cognito sub."); + } } } - + public UserDto getUserDto() { if (this.userDto != null) { return this.userDto; @@ -152,7 +167,7 @@ public UserDto getUserDto() { this.userDto.addAdditionalInfo(getCognitoUser()); return this.userDto; } - + public User getUser() { if (this.user != null) { return this.user; @@ -163,18 +178,22 @@ public User getUser() { } return this.user; } - + public CognitoUser getCognitoUser() { if (this.cognitoUser != null) { return this.cognitoUser; } + if (authDisabled) { + this.cognitoUser = buildDevCognitoUser(); + return this.cognitoUser; + } this.cognitoUser = getUserBySub(cognitoSub); if (this.cognitoUser == null) { throw new UnauthorizedException("Cognito user with sub " + cognitoSub + " not found."); } return this.cognitoUser; } - + /** * Finds a user by Cognito sub. If not found, creates a new user. *@@ -206,7 +225,7 @@ private User findByCognitoSub(String cognitoSub) { throw new UserNotFoundException("User with Cognito ID " + cognitoSub + " not found."); } } - + /** * Extracts the Cognito sub from the Spring Security context. *
@@ -223,12 +242,12 @@ private String getCognitoSubFromSecurityContext() throws UnauthorizedException { if (authentication == null || !authentication.isAuthenticated()) { throw new UnauthorizedException("No authenticated user found."); } - + Jwt jwt = (Jwt) authentication.getPrincipal(); assert jwt != null; return jwt.getClaimAsString("sub"); } - + /** * Retrieves a Cognito user by their unique sub identifier. * @@ -241,7 +260,7 @@ public CognitoUser getUserBySub(String sub) { .userPoolId(userPoolId) .filter(String.format("sub = \"%s\"", sub)) .build(); - + try { ListUsersResponse response = cognitoClient.listUsers(request); if (response.users().isEmpty()) { @@ -253,5 +272,26 @@ public CognitoUser getUserBySub(String sub) { throw new RuntimeException("Error fetching user with sub: " + sub, e); } } - + + /** + * Builds a hardcoded {@link CognitoUser} representing the local dev user. + * + *
Used when {@code auth.disabled=true} (no-auth profile) so that the rest of the + * application can treat the dev user exactly like a real Cognito-authenticated user. + * + * @return a {@link CognitoUser} with stable dev-user values + */ + private CognitoUser buildDevCognitoUser() { + return CognitoUser.builder() + .sub(DEV_USER_SUB) + .username("dev-user") + .email("dev@local") + .preferredUsername("dev-user") + .nickname("Dev User") + .biography("") + .picture(null) + .authProvider("NO_AUTH") + .build(); + } + } diff --git a/minerva-api/src/main/java/io/minerva/api/config/CognitoConfig.java b/minerva-api/src/main/java/io/minerva/api/config/CognitoConfig.java index 61027780..29ce3b10 100644 --- a/minerva-api/src/main/java/io/minerva/api/config/CognitoConfig.java +++ b/minerva-api/src/main/java/io/minerva/api/config/CognitoConfig.java @@ -55,20 +55,23 @@ public class CognitoConfig { /** * Cognito User Pool ID used for JWT validation and user management operations. + * Empty when the {@code no-auth} profile is active — Cognito is not used in that mode. */ - @Value("${secrets.cognito-user-pool-id}") + @Value("${secrets.cognito-user-pool-id:}") private String userPoolId; - + /** * Cognito app client ID associated with the User Pool. + * Empty when the {@code no-auth} profile is active — Cognito is not used in that mode. */ - @Value("${secrets.cognito-user-pool-client-id}") + @Value("${secrets.cognito-user-pool-client-id:}") private String appClientId; - + /** * Domain of the Cognito hosted-UI endpoint. + * Empty when the {@code no-auth} profile is active — Cognito is not used in that mode. */ - @Value("${secrets.cognito-user-pool-domain}") + @Value("${secrets.cognito-user-pool-domain:}") private String userPoolDomain; /** diff --git a/minerva-api/src/main/java/io/minerva/api/config/NoAuthSecurityConfig.java b/minerva-api/src/main/java/io/minerva/api/config/NoAuthSecurityConfig.java new file mode 100644 index 00000000..e60a5bbb --- /dev/null +++ b/minerva-api/src/main/java/io/minerva/api/config/NoAuthSecurityConfig.java @@ -0,0 +1,72 @@ +package io.minerva.api.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.web.cors.CorsConfiguration; +import org.springframework.web.cors.CorsConfigurationSource; +import org.springframework.web.cors.UrlBasedCorsConfigurationSource; + +import java.util.List; + +/** + * Spring Security configuration used when the {@code no-auth} Spring profile is active. + * + *
Disables JWT authentication entirely and permits all HTTP requests without a Bearer token. + * Intended for frictionless local development where setting up AWS Cognito is not required. + * + *
Warning: Never activate the {@code no-auth} profile in {@code test} or + * {@code prod} environments. Auth remains fully enforced in those profiles via + * {@link SecurityConfig}. + * + * @see SecurityConfig + */ +@Configuration +@EnableWebSecurity +@Profile("no-auth") +public class NoAuthSecurityConfig { + + /** + * Configures a security filter chain that permits all HTTP requests without authentication. + * + *
CORS and CSRF settings mirror the production {@link SecurityConfig} to avoid + * behavioural differences between profiles. + * + * @param http the {@link HttpSecurity} to configure + * @return the configured {@link SecurityFilterChain} + * @throws Exception if configuration fails + */ + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http + .cors(cors -> {}) + .csrf(AbstractHttpConfigurer::disable) + .authorizeHttpRequests(authz -> authz.anyRequest().permitAll()); + return http.build(); + } + + /** + * Provides the CORS configuration used by the no-auth security filter chain. + * + *
Identical to the CORS config in {@link SecurityConfig} so that cross-origin behaviour + * is consistent across profiles. + * + * @return a {@link CorsConfigurationSource} applied to all paths ({@code /**}) + */ + @Bean + public CorsConfigurationSource corsConfigurationSource() { + CorsConfiguration configuration = new CorsConfiguration(); + configuration.setAllowedOriginPatterns(List.of("*")); + configuration.setAllowedMethods(List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")); + configuration.setAllowedHeaders(List.of("*")); + configuration.setAllowCredentials(false); + configuration.setExposedHeaders(List.of("Content-Type", "Cache-Control", "Connection")); + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/**", configuration); + return source; + } +} diff --git a/minerva-api/src/main/java/io/minerva/api/config/SecurityConfig.java b/minerva-api/src/main/java/io/minerva/api/config/SecurityConfig.java index 6b0d6962..6a1be05a 100644 --- a/minerva-api/src/main/java/io/minerva/api/config/SecurityConfig.java +++ b/minerva-api/src/main/java/io/minerva/api/config/SecurityConfig.java @@ -3,6 +3,7 @@ import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; import org.springframework.http.HttpMethod; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; @@ -49,6 +50,7 @@ */ @Configuration @EnableWebSecurity +@Profile("!no-auth") public class SecurityConfig { @Value("${secrets.cognito-user-pool-id}") diff --git a/minerva-api/src/main/java/io/minerva/api/controller/PublicController.java b/minerva-api/src/main/java/io/minerva/api/controller/PublicController.java index df519bc1..fcd1d98e 100644 --- a/minerva-api/src/main/java/io/minerva/api/controller/PublicController.java +++ b/minerva-api/src/main/java/io/minerva/api/controller/PublicController.java @@ -4,6 +4,7 @@ import io.minerva.api.model.dto.config.AuthConfigDto; import lombok.NonNull; import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; @@ -22,8 +23,11 @@ @RequestMapping("/api/public") @RequiredArgsConstructor public class PublicController { - + private final CognitoConfig cognitoConfig; + + @Value("${auth.disabled:false}") + private boolean authDisabled; /** * Returns the current operational status of the API service. @@ -45,11 +49,18 @@ public class PublicController { * Exposes Cognito configuration values (e.g., User Pool ID, client ID, region) that the React application needs to * initialise the Cognito SDK. No secrets are exposed. *
+ *+ * When the {@code no-auth} profile is active ({@code auth.disabled=true}), returns + * {@code {"authEnabled": false}} so the frontend can skip the Cognito login flow entirely. + *
* * @return the {@link AuthConfigDto} derived from the active {@link CognitoConfig}; HTTP 200 OK */ @GetMapping("/config") public ResponseEntity<@NonNull AuthConfigDto> getConfig() { + if (authDisabled) { + return ResponseEntity.ok(AuthConfigDto.builder().authEnabled(false).build()); + } AuthConfigDto authConfigDto = AuthConfigDto.fromCognitoConfig(cognitoConfig); return ResponseEntity.ok(authConfigDto); } diff --git a/minerva-api/src/main/java/io/minerva/api/model/dto/config/AuthConfigDto.java b/minerva-api/src/main/java/io/minerva/api/model/dto/config/AuthConfigDto.java index d07528ca..26fa7945 100644 --- a/minerva-api/src/main/java/io/minerva/api/model/dto/config/AuthConfigDto.java +++ b/minerva-api/src/main/java/io/minerva/api/model/dto/config/AuthConfigDto.java @@ -53,7 +53,15 @@ public class AuthConfigDto { */ @Builder.Default private String oauthResponseType = "code"; - + + /** + * Whether authentication is enabled for this environment. + * {@code false} when the {@code no-auth} Spring profile is active — the frontend should skip + * the Cognito login flow entirely and go straight to the app. + */ + @Builder.Default + private boolean authEnabled = true; + /** * Creates an {@link AuthConfigDto} from a {@link CognitoConfig} instance. * diff --git a/minerva-api/src/main/resources/application.yaml b/minerva-api/src/main/resources/application.yaml index 00c59735..0e29d9c4 100644 --- a/minerva-api/src/main/resources/application.yaml +++ b/minerva-api/src/main/resources/application.yaml @@ -145,4 +145,45 @@ aws: access-key: minioadmin secret-key: minioadmin path-style: true -spring.config.import: "aws-secretsmanager:minerva/local?prefix=secrets." \ No newline at end of file +spring.config.import: "aws-secretsmanager:minerva/local?prefix=secrets." + +--- +#--------------------------------- +# No-Auth Profile +# Disables JWT authentication for frictionless local development. +# Use standalone: SPRING_PROFILES_ACTIVE=no-auth +# Use with local stack: SPRING_PROFILES_ACTIVE=local,no-auth +# WARNING: Never activate in test or prod environments. +#--------------------------------- +spring.config.activate.on-profile: no-auth +# Override AWS credentials so startup does not require AWS env vars +spring: + cloud: + aws: + credentials: + access-key: no-auth + secret-key: no-auth +# Local S3 / MinIO config (mirrors the local profile) +aws: + s3: + bucket-name: minerva-local + endpoint: ${S3_ENDPOINT_URL:http://localhost:9002} + access-key: minioadmin + secret-key: minioadmin + path-style: true +# Placeholder values for all secrets so the app starts without Secrets Manager +secrets: + cognito-user-pool-id: no-auth + cognito-user-pool-client-id: no-auth + cognito-user-pool-domain: no-auth + stripe-api-key: no-auth + stripe-webhook-secret: no-auth + stripe-price-id-pro-monthly: no-auth + stripe-price-id-pro-yearly: no-auth + stripe-price-id-enterprise-monthly: no-auth + stripe-price-id-enterprise-yearly: no-auth + openai-api-key: no-auth + llama-parse-api-key: no-auth +# Disable authentication — picked up by SecurityConfig, CurrentUser, PublicController +auth: + disabled: true \ No newline at end of file diff --git a/minerva-hub/CLAUDE.md b/minerva-hub/CLAUDE.md index 7716174d..9ce40671 100644 --- a/minerva-hub/CLAUDE.md +++ b/minerva-hub/CLAUDE.md @@ -175,12 +175,14 @@ All `/app/*` routes are wrapped in `