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 `. - **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. @@ -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 | diff --git a/minerva-api/src/main/java/io/minerva/api/component/CurrentUser.java b/minerva-api/src/main/java/io/minerva/api/component/CurrentUser.java index 3a9e98d8..5388c08d 100644 --- a/minerva-api/src/main/java/io/minerva/api/component/CurrentUser.java +++ b/minerva-api/src/main/java/io/minerva/api/component/CurrentUser.java @@ -36,6 +36,7 @@ *
  • Automatic user lookup or creation based on Cognito authentication
  • *
  • Lazy loading of user data from both local database and Cognito
  • *
  • Unified DTO representation combining local and Cognito user data
  • + *
  • Dev-user fallback when the {@code no-auth} profile is active (auth.disabled=true)
  • * *

    *

    @@ -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: *

      *
    1. Extracts the Cognito sub (subject identifier) from the security context
    2. *
    3. Retrieves the CognitoUser information from AWS Cognito
    4. @@ -116,15 +127,12 @@ public class CurrentUser { *
    5. Creates a unified UserDto with combined data
    6. *
    *

    - *

    - * 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 `` which checks `useAuth()`. ## Authentication - **Provider:** AWS Cognito via AWS Amplify -- **Auth hook:** `src/hooks/useAuth.tsx` - exposes `{ user, loading, ... }`, wraps the Amplify Hub listener for OAuth - callbacks -- **Bootstrap:** `src/main.tsx` calls `Amplify.configure(...)` before rendering +- **Auth hook:** `src/hooks/useAuth.tsx` - exposes `{ user, loading, authEnabled, ... }`, wraps the Amplify Hub + listener for OAuth callbacks +- **Bootstrap:** `src/main.tsx` calls `Amplify.configure(...)` before rendering — skipped when `authEnabled === false` - **Auth config:** fetched at runtime from `GET /api/public/config` (returns Cognito pool/client IDs per environment) - **not hardcoded in the frontend bundle** - **Protected route:** `ProtectedRoute` component in `App.tsx` +- **No-auth mode:** when the backend returns `authEnabled: false` (no-auth Spring profile), `AuthProvider` injects a + synthetic dev user immediately; `AuthPage` redirects to `/app/classes`; `api-client.ts` skips token fetching When the user's session expires, the Amplify library handles token refresh. On auth failure, the user is redirected to `/auth`. diff --git a/minerva-hub/src/hooks/useAuth.tsx b/minerva-hub/src/hooks/useAuth.tsx index 8e593e63..aab432b5 100644 --- a/minerva-hub/src/hooks/useAuth.tsx +++ b/minerva-hub/src/hooks/useAuth.tsx @@ -4,6 +4,7 @@ import {Hub} from 'aws-amplify/utils'; import {toast} from 'sonner'; import {useTranslation} from 'react-i18next'; import {getAmplifyConfig} from '@/lib/amplify'; +import {getAuthConfig} from '@/services/config.service'; // Build Cognito logout URL for full logout (including federated sessions) const getCognitoLogoutUrl = () => { @@ -14,6 +15,13 @@ const getCognitoLogoutUrl = () => { return `https://${domain}/logout?client_id=${userPoolClientId}&logout_uri=${logoutUri}`; }; +/** Synthetic dev user injected when the backend runs with the no-auth profile. */ +const DEV_USER: User = { + username: 'dev-user', + email: 'dev@local', + authProvider: 'NO_AUTH', +}; + /** * Represents the currently authenticated user. */ @@ -54,6 +62,11 @@ interface AuthContextType { redirectToLogin: () => void; /** Re-reads the Amplify session and refreshes `user` and `token` state. */ checkAuthState: () => Promise; + /** + * Whether authentication is enabled for this environment. + * `false` when the backend runs with the no-auth profile. + */ + authEnabled: boolean; } const AuthContext = createContext(undefined); @@ -61,6 +74,10 @@ const AuthContext = createContext(undefined); /** * Provides authentication state to the component tree. * + * When the backend reports `authEnabled: false` (no-auth profile), a synthetic dev user is + * set immediately and all Amplify interactions are skipped. This allows the frontend to work + * without Cognito credentials during local development. + * * Wraps AWS Amplify's Hub listener to handle OAuth callbacks and session refresh. * Must be mounted at the root of the application before any component calls {@link useAuth}. * @@ -69,11 +86,19 @@ const AuthContext = createContext(undefined); */ export function AuthProvider({children}: { children: ReactNode }) { const {t} = useTranslation(); - const [user, setUser] = useState(null); + const authConfig = getAuthConfig(); + const authEnabled = authConfig.authEnabled !== false; + + const [user, setUser] = useState(authEnabled ? null : DEV_USER); const [token, setToken] = useState(null); - const [loading, setLoading] = useState(true); + // loading starts as false in no-auth mode — dev user is already available + const [loading, setLoading] = useState(authEnabled); useEffect(() => { + if (!authEnabled) { + return; + } + // Check if we're in an OAuth callback (has ?code= parameter) const urlParams = new URLSearchParams(window.location.search); const hasAuthCode = urlParams.has('code'); @@ -112,6 +137,9 @@ export function AuthProvider({children}: { children: ReactNode }) { }, []); const checkAuthState = async () => { + if (!authEnabled) { + return; + } try { const currentUser = await getCurrentUser(); const session = await fetchAuthSession(); @@ -138,6 +166,11 @@ export function AuthProvider({children}: { children: ReactNode }) { }; const signOut = async () => { + if (!authEnabled) { + // No real session to clear in no-auth mode + window.location.href = '/'; + return; + } try { await amplifySignOut(); @@ -154,6 +187,9 @@ export function AuthProvider({children}: { children: ReactNode }) { }; const redirectToLogin = () => { + if (!authEnabled) { + return; + } const {origin, hostname} = window.location if (hostname.startsWith('campus.')) { window.location.href = origin.replace('//campus.', '//auth.') + '/auth' @@ -169,6 +205,7 @@ export function AuthProvider({children}: { children: ReactNode }) { signOut, redirectToLogin, checkAuthState, + authEnabled, }; return {children}; diff --git a/minerva-hub/src/lib/api-client.ts b/minerva-hub/src/lib/api-client.ts index 36aba669..9aeedfea 100644 --- a/minerva-hub/src/lib/api-client.ts +++ b/minerva-hub/src/lib/api-client.ts @@ -1,6 +1,7 @@ import axios, {AxiosRequestConfig} from 'axios' import {fetchAuthSession, signOut} from '@aws-amplify/auth'; import {isSubscriptionLimitError} from '@/utils/subscription-errors'; +import {getAuthConfig} from '@/services/config.service'; /** * Sends an HTTP GET request via the shared Axios instance and returns the response data. @@ -141,6 +142,10 @@ const apiClient = axios.create({ // Request interceptor for adding auth token from Amplify apiClient.interceptors.request.use( async (config) => { + // Skip Amplify session in no-auth mode — the backend accepts requests without a token + if (getAuthConfig().authEnabled === false) { + return config; + } try { const session = await fetchAuthSession(); const token = session.tokens?.accessToken?.toString(); @@ -162,7 +167,7 @@ apiClient.interceptors.request.use( apiClient.interceptors.response.use( (response) => response, async (error) => { - if (error.response?.status === 401) { + if (error.response?.status === 401 && getAuthConfig().authEnabled !== false) { try { await signOut({global: true}); } catch (e) { diff --git a/minerva-hub/src/main.tsx b/minerva-hub/src/main.tsx index bba7c359..8d55455c 100644 --- a/minerva-hub/src/main.tsx +++ b/minerva-hub/src/main.tsx @@ -12,11 +12,16 @@ import {LanguageProvider} from './contexts/LanguageContext' /** * Bootstraps the application by loading auth configuration and i18n, then renders the React tree. * + * When the backend reports `authEnabled: false` (no-auth profile), Amplify is not configured + * — the frontend skips Cognito entirely and uses a synthetic dev user. + * * @returns A promise that resolves once the app is mounted. */ async function bootstrap() { - await Promise.all([loadAuthConfig(), setupI18n()]) - Amplify.configure(getAmplifyConfig()) + const [authConfig] = await Promise.all([loadAuthConfig(), setupI18n()]) + if (authConfig.authEnabled !== false) { + Amplify.configure(getAmplifyConfig()) + } ReactDOM.createRoot(document.getElementById('root')!).render( diff --git a/minerva-hub/src/pages/auth/AuthPage.tsx b/minerva-hub/src/pages/auth/AuthPage.tsx index c7054d35..4e0019cb 100644 --- a/minerva-hub/src/pages/auth/AuthPage.tsx +++ b/minerva-hub/src/pages/auth/AuthPage.tsx @@ -7,6 +7,7 @@ import {Link, useNavigate} from 'react-router-dom' import {Brain, FileText, GraduationCap, Sparkles, Target} from 'lucide-react' import {signInWithRedirect} from '@aws-amplify/auth' import {useTranslation} from 'react-i18next' +import {useAuth} from '@/hooks/useAuth' const theme: Theme = { name: 'minerva-theme', @@ -215,16 +216,30 @@ function AuthenticatedRedirect() { * Renders sign-in, sign-up, forgot-password, and reset-password flows using AWS Cognito * with support for Google OAuth via Amplify's hosted UI. * + * When the backend runs with the no-auth profile (`authEnabled: false`), this page + * immediately redirects to `/app/classes` — no login screen is shown. + * * @returns The rendered authentication page element. */ export function AuthPage() { const {t, i18n} = useTranslation() + const {authEnabled} = useAuth() + const navigate = useNavigate() + // Redirect to the app immediately when auth is disabled (no-auth profile) useEffect(() => { + if (!authEnabled) { + navigate('/app/classes', {replace: true}) + } + }, [authEnabled, navigate]) + + useEffect(() => { + if (!authEnabled) return I18n.putVocabularies(amplifyTranslations) - }, []) + }, [authEnabled]) useEffect(() => { + if (!authEnabled) return const vocabulary = { [DefaultTexts.SIGN_IN]: t('auth.forms.actions.signIn'), [DefaultTexts.SIGN_IN_BUTTON]: t('auth.forms.actions.signIn'), @@ -242,7 +257,8 @@ export function AuthPage() { const language = i18n.language || i18n.resolvedLanguage || 'en' I18n.putVocabulariesForLanguage(language, vocabulary) I18n.setLanguage(language) - }, [i18n.language, i18n.resolvedLanguage, t]) + }, [authEnabled, i18n.language, i18n.resolvedLanguage, t]) + const highlights = useMemo(() => (t('auth.highlights', {returnObjects: true}) as Array<{ title: string; description: string; @@ -323,6 +339,15 @@ export function AuthPage() { }, }, }), [t]) + + if (!authEnabled) { + return ( +
    +
    +
    + ) + } + return (
    {/* ─── LEFT PANEL ─── */} diff --git a/minerva-hub/src/types/index.ts b/minerva-hub/src/types/index.ts index 40cee9db..69f63163 100644 --- a/minerva-hub/src/types/index.ts +++ b/minerva-hub/src/types/index.ts @@ -17,6 +17,13 @@ export interface AuthConfig { oauthScopes: string[] /** OAuth response type, typically `'code'`. */ oauthResponseType: string + /** + * Whether authentication is enabled for this environment. + * `false` when the backend runs with the `no-auth` Spring profile — the frontend should skip + * the Cognito login flow and go straight to the app. + * Defaults to `true` when absent (all prod/test environments). + */ + authEnabled?: boolean } // ============================================================================