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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 23 additions & 1 deletion minerva-api/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -160,6 +179,9 @@ Everything else requires a valid Cognito JWT in `Authorization: Bearer <token>`.
- **User pool ID:** read from `secrets.cognito-user-pool-id` (injected from AWS Secrets Manager)
- **Current user resolution:** `component/CurrentUser.java` extracts the Cognito `sub` from the JWT principal
- **Profile:** Configurable per Spring profile - `local`, `test`, `prod` user pools are separate Cognito resources
- **No-auth mode:** `NoAuthSecurityConfig.java` (active only with `no-auth` profile) permits all requests;
`CurrentUser` injects the hardcoded dev user sub without JWT. `GET /api/public/config` returns
`{"authEnabled": false}` to signal the frontend to skip the login flow.

**Do not** hardcode User Pool IDs. They come from secrets. Changing the pool ID requires updating the Terraform
workspace variable.
Expand Down Expand Up @@ -373,7 +395,7 @@ Key environment variables expected at runtime:

| Variable | Default (local) | Purpose |
|--------------------------|---------------------------------|--------------------------------------|
| `SPRING_PROFILES_ACTIVE` | - | Required: `local`, `test`, or `prod` |
| `SPRING_PROFILES_ACTIVE` | - | Required: `local`, `test`, `prod`, or `no-auth` (dev without Cognito) |
| `NEO4J_URI` | `bolt://localhost:47686` | Neo4j bolt URL |
| `NEO4J_USERNAME` | `neo4j` | Neo4j auth |
| `NEO4J_PASSWORD` | `minerva123` | Neo4j auth |
Expand Down
96 changes: 68 additions & 28 deletions minerva-api/src/main/java/io/minerva/api/component/CurrentUser.java
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@
* <li>Automatic user lookup or creation based on Cognito authentication</li>
* <li>Lazy loading of user data from both local database and Cognito</li>
* <li>Unified DTO representation combining local and Cognito user data</li>
* <li>Dev-user fallback when the {@code no-auth} profile is active (auth.disabled=true)</li>
* </ul>
* </p>
* <p>
Expand Down Expand Up @@ -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.
* <p>
* 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.
* </p>
* <p>
* In normal mode this constructor performs the following operations:
* <ol>
* <li>Extracts the Cognito sub (subject identifier) from the security context</li>
* <li>Retrieves the CognitoUser information from AWS Cognito</li>
Expand All @@ -116,15 +127,12 @@ public class CurrentUser {
* <li>Creates a unified UserDto with combined data</li>
* </ol>
* </p>
* <p>
* 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.
* </p>
*
* @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(
Expand All @@ -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;
Expand All @@ -152,7 +167,7 @@ public UserDto getUserDto() {
this.userDto.addAdditionalInfo(getCognitoUser());
return this.userDto;
}

public User getUser() {
if (this.user != null) {
return this.user;
Expand All @@ -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.
* <p>
Expand Down Expand Up @@ -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.
* <p>
Expand All @@ -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.
*
Expand All @@ -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()) {
Expand All @@ -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.
*
* <p>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();
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -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.
*
* <p>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.
*
* <p><strong>Warning:</strong> 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.
*
* <p>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.
*
* <p>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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -49,6 +50,7 @@
*/
@Configuration
@EnableWebSecurity
@Profile("!no-auth")
public class SecurityConfig {

@Value("${secrets.cognito-user-pool-id}")
Expand Down
Loading
Loading