From bbe8916e826efee76cc0eb61135516076598a0d9 Mon Sep 17 00:00:00 2001 From: Nessar Date: Tue, 14 Jul 2026 20:22:43 +0200 Subject: [PATCH] feat: rework OIDC to BFF (Backend-For-Frontend) cookie-session pattern - backend: switch from OAuth2 Resource Server to OAuth2 Client (spring-boot-starter-oauth2-client) - backend: configure Spring Security oauth2Login() with session cookie (JSESSIONID HttpOnly) - backend: add UserController exposing /api/user to report authenticated user info - backend: configure GET /logout with RP-initiated end-session support - backend: update HelloController to use @AuthenticationPrincipal OAuth2User - backend: update all tests to use .with(oidcLogin()) mock - backend: explicitly configure OIDC provider endpoints to bypass discovery at test time - frontend: remove angular-oauth2-oidc and @ngx-env/builder dependencies - frontend: revert angular.json builders to standard @angular/build:* - frontend: replace client-side OIDC flow with /api/user check + window.location redirects - frontend: update app.config.ts and config.service.ts to remove OIDC configuration - frontend: rewrite all unit tests to mock /api/user endpoint instead of OAuthService - compose: move OIDC_CLIENT_ID and OIDC_CLIENT_SECRET to backend environment variables - compose/dockerfile: remove frontend build-time OIDC ARG/ENV secrets --- backend/pom.xml | 2 +- .../backend/HelloController.java | 11 ++- .../lmnporganizer/backend/SecurityConfig.java | 22 ++++- .../backend/controller/UserController.java | 26 +++++ .../src/main/resources/application.properties | 12 ++- .../backend/HelloControllerTest.java | 10 +- .../controller/ApartmentControllerTest.java | 22 ++--- .../controller/UserControllerTest.java | 43 +++++++++ compose.yml | 6 +- frontend/Dockerfile | 9 -- frontend/angular.json | 12 +-- frontend/bun.lock | 3 - frontend/package.json | 1 - frontend/src/app/app.config.ts | 17 +--- frontend/src/app/app.spec.ts | 95 +++++++++---------- frontend/src/app/app.ts | 49 ++++++---- frontend/src/app/config.service.spec.ts | 18 ---- frontend/src/app/config.service.ts | 12 --- frontend/src/env.d.ts | 22 ----- 19 files changed, 198 insertions(+), 194 deletions(-) create mode 100644 backend/src/main/java/fr/nessar/lmnporganizer/backend/controller/UserController.java create mode 100644 backend/src/test/java/fr/nessar/lmnporganizer/backend/controller/UserControllerTest.java delete mode 100644 frontend/src/env.d.ts diff --git a/backend/pom.xml b/backend/pom.xml index 0f95b9c..cbf1797 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -40,7 +40,7 @@ org.springframework.boot - spring-boot-starter-security-oauth2-resource-server + spring-boot-starter-oauth2-client org.springframework.boot diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/backend/HelloController.java b/backend/src/main/java/fr/nessar/lmnporganizer/backend/HelloController.java index eb769b2..f0caa0c 100644 --- a/backend/src/main/java/fr/nessar/lmnporganizer/backend/HelloController.java +++ b/backend/src/main/java/fr/nessar/lmnporganizer/backend/HelloController.java @@ -1,7 +1,7 @@ package fr.nessar.lmnporganizer.backend; import org.springframework.security.core.annotation.AuthenticationPrincipal; -import org.springframework.security.oauth2.jwt.Jwt; +import org.springframework.security.oauth2.core.user.OAuth2User; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RestController; @@ -9,10 +9,13 @@ import org.springframework.web.bind.annotation.RestController; public class HelloController { @GetMapping("/api/hello") - public String hello(@AuthenticationPrincipal Jwt jwt) { - String name = jwt.getClaimAsString("preferred_username"); + public String hello(@AuthenticationPrincipal OAuth2User user) { + String name = user.getAttribute("preferred_username"); if (name == null) { - name = jwt.getSubject(); + name = user.getAttribute("name"); + } + if (name == null) { + name = user.getName(); } return "Hello, " + name + "!"; } diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/backend/SecurityConfig.java b/backend/src/main/java/fr/nessar/lmnporganizer/backend/SecurityConfig.java index df868d9..280032c 100644 --- a/backend/src/main/java/fr/nessar/lmnporganizer/backend/SecurityConfig.java +++ b/backend/src/main/java/fr/nessar/lmnporganizer/backend/SecurityConfig.java @@ -2,9 +2,13 @@ package fr.nessar.lmnporganizer.backend; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.http.HttpMethod; +import org.springframework.http.HttpStatus; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.HttpStatusEntryPoint; +import org.springframework.security.web.servlet.util.matcher.PathPatternRequestMatcher; @Configuration @EnableWebSecurity @@ -16,11 +20,23 @@ public class SecurityConfig { .authorizeHttpRequests(authorize -> authorize .requestMatchers("/api/hello").authenticated() .requestMatchers("/api/apartments", "/api/apartments/**").authenticated() + .requestMatchers("/api/user").permitAll() .anyRequest().permitAll() ) - .oauth2ResourceServer(oauth2 -> oauth2 - .jwt(jwt -> {}) - ); + .exceptionHandling(exceptions -> exceptions + .defaultAuthenticationEntryPointFor( + new HttpStatusEntryPoint(HttpStatus.UNAUTHORIZED), + PathPatternRequestMatcher.pathPattern("/api/**") + ) + ) + .oauth2Login(oauth2 -> oauth2 + .defaultSuccessUrl("/", true) + ) + .logout(logout -> logout + .logoutRequestMatcher(PathPatternRequestMatcher.pathPattern(HttpMethod.GET, "/logout")) + .logoutSuccessUrl("/") + ) + .csrf(csrf -> csrf.disable()); return http.build(); } } diff --git a/backend/src/main/java/fr/nessar/lmnporganizer/backend/controller/UserController.java b/backend/src/main/java/fr/nessar/lmnporganizer/backend/controller/UserController.java new file mode 100644 index 0000000..f4e7804 --- /dev/null +++ b/backend/src/main/java/fr/nessar/lmnporganizer/backend/controller/UserController.java @@ -0,0 +1,26 @@ +package fr.nessar.lmnporganizer.backend.controller; + +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.security.oauth2.core.oidc.user.OidcUser; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Map; + +@RestController +@RequestMapping("/api") +public class UserController { + + @GetMapping("/user") + public Map getUser(@AuthenticationPrincipal OidcUser principal) { + if (principal == null) { + return Map.of("authenticated", false); + } + return Map.of( + "authenticated", true, + "username", principal.getPreferredUsername() != null ? principal.getPreferredUsername() : principal.getName(), + "email", principal.getEmail() != null ? principal.getEmail() : "" + ); + } +} diff --git a/backend/src/main/resources/application.properties b/backend/src/main/resources/application.properties index c203e96..0f05be9 100644 --- a/backend/src/main/resources/application.properties +++ b/backend/src/main/resources/application.properties @@ -1,3 +1,13 @@ spring.application.name=backend spring.mongodb.uri=${SPRING_DATA_MONGODB_URI:mongodb://localhost:27017/lmnp} -spring.security.oauth2.resourceserver.jwt.issuer-uri=${OIDC_ISSUER_URI:http://localhost:8090/realms/voidAuth} +spring.security.oauth2.client.registration.oidc.client-id=${OIDC_CLIENT_ID:lmnp-client} +spring.security.oauth2.client.registration.oidc.client-secret=${OIDC_CLIENT_SECRET:lmnp-secret} +spring.security.oauth2.client.registration.oidc.scope=openid,profile,email +spring.security.oauth2.client.registration.oidc.authorization-grant-type=authorization_code +spring.security.oauth2.client.registration.oidc.redirect-uri={baseUrl}/login/oauth2/code/{registrationId} + +spring.security.oauth2.client.provider.oidc.authorization-uri=${OIDC_ISSUER_URI:http://localhost:8090/realms/voidAuth}/protocol/openid-connect/auth +spring.security.oauth2.client.provider.oidc.token-uri=${OIDC_ISSUER_URI:http://localhost:8090/realms/voidAuth}/protocol/openid-connect/token +spring.security.oauth2.client.provider.oidc.user-info-uri=${OIDC_ISSUER_URI:http://localhost:8090/realms/voidAuth}/protocol/openid-connect/userinfo +spring.security.oauth2.client.provider.oidc.jwk-set-uri=${OIDC_ISSUER_URI:http://localhost:8090/realms/voidAuth}/protocol/openid-connect/certs +spring.security.oauth2.client.provider.oidc.user-name-attribute=preferred_username diff --git a/backend/src/test/java/fr/nessar/lmnporganizer/backend/HelloControllerTest.java b/backend/src/test/java/fr/nessar/lmnporganizer/backend/HelloControllerTest.java index 5d56716..508a684 100644 --- a/backend/src/test/java/fr/nessar/lmnporganizer/backend/HelloControllerTest.java +++ b/backend/src/test/java/fr/nessar/lmnporganizer/backend/HelloControllerTest.java @@ -6,14 +6,11 @@ import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.test.web.servlet.MockMvc; -import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.oidcLogin; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.content; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; -import org.springframework.security.oauth2.jwt.JwtDecoder; -import org.springframework.test.context.bean.override.mockito.MockitoBean; - @SpringBootTest(properties = "lmnp.data-initializer.enabled=false") @AutoConfigureMockMvc class HelloControllerTest { @@ -21,9 +18,6 @@ class HelloControllerTest { @Autowired private MockMvc mockMvc; - @MockitoBean - private JwtDecoder jwtDecoder; - @Test void whenRequestHelloWithoutToken_thenReturns401() throws Exception { mockMvc.perform(get("/api/hello")) @@ -33,7 +27,7 @@ class HelloControllerTest { @Test void whenRequestHelloWithToken_thenReturnsGreeting() throws Exception { mockMvc.perform(get("/api/hello") - .with(jwt().jwt(builder -> builder.claim("preferred_username", "John Doe")))) + .with(oidcLogin().idToken(token -> token.claim("preferred_username", "John Doe")))) .andExpect(status().isOk()) .andExpect(content().string("Hello, John Doe!")); } diff --git a/backend/src/test/java/fr/nessar/lmnporganizer/backend/controller/ApartmentControllerTest.java b/backend/src/test/java/fr/nessar/lmnporganizer/backend/controller/ApartmentControllerTest.java index 815166a..ed89219 100644 --- a/backend/src/test/java/fr/nessar/lmnporganizer/backend/controller/ApartmentControllerTest.java +++ b/backend/src/test/java/fr/nessar/lmnporganizer/backend/controller/ApartmentControllerTest.java @@ -7,7 +7,6 @@ import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.http.MediaType; -import org.springframework.security.oauth2.jwt.JwtDecoder; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; @@ -17,7 +16,7 @@ import java.util.Optional; import static org.mockito.ArgumentMatchers.any; import static org.mockito.Mockito.*; -import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.jwt; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.oidcLogin; import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; @@ -32,9 +31,6 @@ class ApartmentControllerTest { @MockitoBean private ApartmentRepository apartmentRepository; - @MockitoBean - private JwtDecoder jwtDecoder; - @Test void whenGetApartmentsWithoutToken_thenUnauthorized() throws Exception { mockMvc.perform(get("/api/apartments")) @@ -46,7 +42,7 @@ class ApartmentControllerTest { Apartment apt = new Apartment("1", "default-landlord", "Appartement 1", "Adresse 1", 150000.0, "Desc"); when(apartmentRepository.findAll()).thenReturn(List.of(apt)); - mockMvc.perform(get("/api/apartments").with(jwt())) + mockMvc.perform(get("/api/apartments").with(oidcLogin())) .andExpect(status().isOk()) .andExpect(jsonPath("$[0].id").value("1")) .andExpect(jsonPath("$[0].name").value("Appartement 1")); @@ -56,7 +52,7 @@ class ApartmentControllerTest { void whenGetApartmentByIdNotFound_thenNotFound() throws Exception { when(apartmentRepository.findById("999")).thenReturn(Optional.empty()); - mockMvc.perform(get("/api/apartments/999").with(jwt())) + mockMvc.perform(get("/api/apartments/999").with(oidcLogin())) .andExpect(status().isNotFound()); } @@ -65,7 +61,7 @@ class ApartmentControllerTest { Apartment apt = new Apartment("1", "default-landlord", "Appartement 1", "Adresse 1", 150000.0, "Desc"); when(apartmentRepository.findById("1")).thenReturn(Optional.of(apt)); - mockMvc.perform(get("/api/apartments/1").with(jwt())) + mockMvc.perform(get("/api/apartments/1").with(oidcLogin())) .andExpect(status().isOk()) .andExpect(jsonPath("$.id").value("1")); } @@ -76,7 +72,7 @@ class ApartmentControllerTest { when(apartmentRepository.save(any(Apartment.class))).thenReturn(savedApt); mockMvc.perform(post("/api/apartments") - .with(jwt()) + .with(oidcLogin()) .contentType(MediaType.APPLICATION_JSON) .content("{\"name\":\"New Apartment\",\"address\":\"Address\",\"purchasePrice\":200000.0,\"description\":\"Desc\"}")) .andExpect(status().isCreated()) @@ -89,7 +85,7 @@ class ApartmentControllerTest { when(apartmentRepository.findById("999")).thenReturn(Optional.empty()); mockMvc.perform(put("/api/apartments/999") - .with(jwt()) + .with(oidcLogin()) .contentType(MediaType.APPLICATION_JSON) .content("{\"name\":\"New Name\",\"address\":\"New Address\",\"purchasePrice\":120000.0,\"description\":\"New Desc\"}")) .andExpect(status().isNotFound()); @@ -104,7 +100,7 @@ class ApartmentControllerTest { when(apartmentRepository.save(any(Apartment.class))).thenReturn(updated); mockMvc.perform(put("/api/apartments/1") - .with(jwt()) + .with(oidcLogin()) .contentType(MediaType.APPLICATION_JSON) .content("{\"name\":\"New Name\",\"address\":\"New Address\",\"purchasePrice\":120000.0,\"description\":\"New Desc\"}")) .andExpect(status().isOk()) @@ -116,7 +112,7 @@ class ApartmentControllerTest { void whenDeleteApartmentNotFound_thenNotFound() throws Exception { when(apartmentRepository.existsById("999")).thenReturn(false); - mockMvc.perform(delete("/api/apartments/999").with(jwt())) + mockMvc.perform(delete("/api/apartments/999").with(oidcLogin())) .andExpect(status().isNotFound()); } @@ -125,7 +121,7 @@ class ApartmentControllerTest { when(apartmentRepository.existsById("1")).thenReturn(true); doNothing().when(apartmentRepository).deleteById("1"); - mockMvc.perform(delete("/api/apartments/1").with(jwt())) + mockMvc.perform(delete("/api/apartments/1").with(oidcLogin())) .andExpect(status().isNoContent()); verify(apartmentRepository, times(1)).deleteById("1"); diff --git a/backend/src/test/java/fr/nessar/lmnporganizer/backend/controller/UserControllerTest.java b/backend/src/test/java/fr/nessar/lmnporganizer/backend/controller/UserControllerTest.java new file mode 100644 index 0000000..79a5146 --- /dev/null +++ b/backend/src/test/java/fr/nessar/lmnporganizer/backend/controller/UserControllerTest.java @@ -0,0 +1,43 @@ +package fr.nessar.lmnporganizer.backend.controller; + +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.test.web.servlet.MockMvc; + +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.oidcLogin; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest(properties = "lmnp.data-initializer.enabled=false") +@AutoConfigureMockMvc +class UserControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Test + void whenGetUserUnauthenticated_thenReturnsAuthenticatedFalse() throws Exception { + mockMvc.perform(get("/api/user")) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.authenticated").value(false)); + } + + @Test + void whenGetUserAuthenticated_thenReturnsUserDetails() throws Exception { + mockMvc.perform(get("/api/user") + .with(oidcLogin() + .idToken(token -> token + .claim("preferred_username", "johndoe") + .claim("email", "john@example.com") + ) + ) + ) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.authenticated").value(true)) + .andExpect(jsonPath("$.username").value("johndoe")) + .andExpect(jsonPath("$.email").value("john@example.com")); + } +} diff --git a/compose.yml b/compose.yml index a002249..15b32f7 100644 --- a/compose.yml +++ b/compose.yml @@ -26,6 +26,8 @@ services: environment: SPRING_DATA_MONGODB_URI: mongodb://admin:adminpassword@mongodb:27017/lmnp?authSource=admin OIDC_ISSUER_URI: ${OIDC_ISSUER_URI:-http://localhost:8090/realms/voidAuth} + OIDC_CLIENT_ID: ${OIDC_CLIENT_ID:-lmnp-client} + OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET:-lmnp-secret} depends_on: mongodb: condition: service_healthy @@ -34,10 +36,6 @@ services: build: context: ./frontend dockerfile: Dockerfile - args: - - OIDC_ISSUER_URI=${OIDC_ISSUER_URI:-http://localhost:8090/realms/voidAuth} - - OIDC_CLIENT_ID=${OIDC_CLIENT_ID:-lmnp-client} - - OIDC_CLIENT_SECRET=${OIDC_CLIENT_SECRET:-lmnp-secret} container_name: lmnp-frontend ports: - "80:80" diff --git a/frontend/Dockerfile b/frontend/Dockerfile index d85dcf0..bb1f947 100644 --- a/frontend/Dockerfile +++ b/frontend/Dockerfile @@ -11,15 +11,6 @@ RUN bun install --frozen-lockfile COPY . . -# Accept build arguments for environment variables -ARG OIDC_ISSUER_URI -ARG OIDC_CLIENT_ID -ARG OIDC_CLIENT_SECRET - -# Set build-time environment variables for @ngx-env/builder -ENV OIDC_ISSUER_URI=$OIDC_ISSUER_URI -ENV OIDC_CLIENT_ID=$OIDC_CLIENT_ID -ENV OIDC_CLIENT_SECRET=$OIDC_CLIENT_SECRET ENV CI=true ENV NG_CLI_ANALYTICS=false diff --git a/frontend/angular.json b/frontend/angular.json index 8cc12b7..20b03df 100644 --- a/frontend/angular.json +++ b/frontend/angular.json @@ -15,7 +15,7 @@ "prefix": "app", "architect": { "build": { - "builder": "@ngx-env/builder:application", + "builder": "@angular/build:application", "options": { "browser": "src/main.ts", "tsConfig": "tsconfig.app.json", @@ -25,10 +25,7 @@ "input": "public" } ], - "styles": ["src/styles.css"], - "ngxEnv": { - "prefix": "OIDC_" - } + "styles": ["src/styles.css"] }, "configurations": { "production": { @@ -55,7 +52,7 @@ "defaultConfiguration": "production" }, "serve": { - "builder": "@ngx-env/builder:dev-server", + "builder": "@angular/build:dev-server", "configurations": { "production": { "buildTarget": "lmnp-frontend:build:production" @@ -68,10 +65,9 @@ "options": {} }, "test": { - "builder": "@ngx-env/builder:unit-test", + "builder": "@angular/build:unit-test", "options": { "tsConfig": "tsconfig.spec.json", - "coverage": true, "coverageExclude": ["**/*.html", "src/main.ts"] } }, diff --git a/frontend/bun.lock b/frontend/bun.lock index 24e4ce1..8f15823 100644 --- a/frontend/bun.lock +++ b/frontend/bun.lock @@ -11,7 +11,6 @@ "@angular/forms": "^22.0.0", "@angular/platform-browser": "^22.0.0", "@angular/router": "^22.0.0", - "angular-oauth2-oidc": "^22.0.2", "rxjs": "~7.8.0", "tslib": "^2.3.0", }, @@ -586,8 +585,6 @@ "angular-eslint": ["angular-eslint@22.1.0", "", { "dependencies": { "@angular-devkit/core": ">= 22.0.0 < 23.0.0", "@angular-devkit/schematics": ">= 22.0.0 < 23.0.0", "@angular-eslint/builder": "22.1.0", "@angular-eslint/eslint-plugin": "22.1.0", "@angular-eslint/eslint-plugin-template": "22.1.0", "@angular-eslint/schematics": "22.1.0", "@angular-eslint/template-parser": "22.1.0", "@typescript-eslint/types": "^8.0.0", "@typescript-eslint/utils": "^8.0.0" }, "peerDependencies": { "@angular/cli": ">= 22.0.0 < 23.0.0", "eslint": "^9.0.0 || ^10.0.0", "typescript": "*", "typescript-eslint": "^8.0.0" } }, "sha512-LU5a6MOSeQTETnc4xaHi10p9S1cstNczr93sChi3rx8IzgH4+risIsjjQHVLSnQDrSGH2O8jAGCaChker7f7TQ=="], - "angular-oauth2-oidc": ["angular-oauth2-oidc@22.0.2", "", { "dependencies": { "tslib": "^2.5.2" }, "peerDependencies": { "@angular/common": ">=22.0.0", "@angular/core": ">=22.0.0" } }, "sha512-MDzmf0quLVnsCzVp/G7Pr6YTSMoMMo62u8QFqJzM3madQmWV5JI4dMppvuTPswH+4V2gx4zPXdCjnn8ikVZX+A=="], - "ansi-escapes": ["ansi-escapes@7.3.0", "", { "dependencies": { "environment": "^1.0.0" } }, "sha512-BvU8nYgGQBxcmMuEeUEmNTvrMVjJNSH7RgW24vXexN4Ven6qCvy4TntnvlnwnMLTVlcRQQdbRY8NKnaIoeWDNg=="], "ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="], diff --git a/frontend/package.json b/frontend/package.json index 5f2831f..e30bc50 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -18,7 +18,6 @@ "@angular/forms": "^22.0.0", "@angular/platform-browser": "^22.0.0", "@angular/router": "^22.0.0", - "angular-oauth2-oidc": "^22.0.2", "rxjs": "~7.8.0", "tslib": "^2.3.0" }, diff --git a/frontend/src/app/app.config.ts b/frontend/src/app/app.config.ts index 4e3c92c..0df0178 100644 --- a/frontend/src/app/app.config.ts +++ b/frontend/src/app/app.config.ts @@ -1,7 +1,6 @@ import { ApplicationConfig, provideBrowserGlobalErrorListeners, provideAppInitializer, inject } from '@angular/core'; import { provideRouter } from '@angular/router'; import { provideHttpClient } from '@angular/common/http'; -import { provideOAuthClient, OAuthService } from 'angular-oauth2-oidc'; import { routes } from './app.routes'; import { ConfigService } from './config.service'; @@ -11,23 +10,9 @@ export const appConfig: ApplicationConfig = { provideBrowserGlobalErrorListeners(), provideRouter(routes), provideHttpClient(), - provideOAuthClient(), provideAppInitializer(() => { const configService = inject(ConfigService); - const oauthService = inject(OAuthService); - return configService.loadConfig().then(() => { - oauthService.configure({ - issuer: configService.oidcIssuer, - redirectUri: window.location.origin + '/', - clientId: configService.clientId, - dummyClientSecret: configService.clientSecret, - responseType: 'code', - scope: 'openid profile email', - showDebugInformation: true, - requireHttps: false - }); - return oauthService.loadDiscoveryDocumentAndTryLogin(); - }); + return configService.loadConfig(); }) ] }; diff --git a/frontend/src/app/app.spec.ts b/frontend/src/app/app.spec.ts index e780d67..abaa45f 100644 --- a/frontend/src/app/app.spec.ts +++ b/frontend/src/app/app.spec.ts @@ -1,27 +1,27 @@ import { TestBed } from '@angular/core/testing'; import { App } from './app'; -import { OAuthService } from 'angular-oauth2-oidc'; import { HttpClient } from '@angular/common/http'; import { ApartmentService } from './apartment.service'; import { of, throwError } from 'rxjs'; import { vi } from 'vitest'; describe('App', () => { - let mockOAuthService: any; let mockHttpClient: any; let mockApartmentService: any; + let mockUserResponse: { authenticated: boolean; username: string; email: string }; + let originalLocation: any; + let locationMock: { href: string; origin: string }; beforeEach(async () => { - mockOAuthService = { - hasValidAccessToken: () => false, - getIdentityClaims: () => null, - getAccessToken: () => 'mock-token', - initCodeFlow: vi.fn(), - revokeTokenAndLogout: vi.fn() - }; + mockUserResponse = { authenticated: false, username: '', email: '' }; mockHttpClient = { - get: () => of('Hello from mocked API!') + get: (url: string) => { + if (url === '/api/user') { + return of(mockUserResponse); + } + return of('Hello from mocked API!'); + } }; mockApartmentService = { @@ -34,16 +34,27 @@ describe('App', () => { deleteApartment: () => of(undefined) }; + originalLocation = window.location; + delete (window as any).location; + locationMock = { + href: '', + origin: 'http://localhost' + }; + (window as any).location = locationMock; + await TestBed.configureTestingModule({ imports: [App], providers: [ - { provide: OAuthService, useValue: mockOAuthService }, { provide: HttpClient, useValue: mockHttpClient }, { provide: ApartmentService, useValue: mockApartmentService } ] }).compileComponents(); }); + afterEach(() => { + (window as any).location = originalLocation; + }); + it('should create the app', () => { const fixture = TestBed.createComponent(App); const app = fixture.componentInstance; @@ -52,51 +63,38 @@ describe('App', () => { it('should render title', async () => { const fixture = TestBed.createComponent(App); + fixture.detectChanges(); await fixture.whenStable(); const compiled = fixture.nativeElement as HTMLElement; expect(compiled.querySelector('h1')?.textContent).toContain('LMNP Organizer'); }); - it('should return login status based on OAuthService', () => { + it('should return login status based on user endpoint response', async () => { const fixture = TestBed.createComponent(App); const app = fixture.componentInstance; - - mockOAuthService.hasValidAccessToken = () => true; - expect(app.isLoggedIn).toBe(true); - mockOAuthService.hasValidAccessToken = () => false; + // Initially unauthenticated + fixture.detectChanges(); + await fixture.whenStable(); expect(app.isLoggedIn).toBe(false); - }); - - it('should return username and email from identity claims', () => { - const fixture = TestBed.createComponent(App); - const app = fixture.componentInstance; - - // Default username when no claims - expect(app.username).toBe(''); - - // Custom claims - mockOAuthService.getIdentityClaims = () => ({ - preferred_username: 'Alice', - email: 'alice@example.com' - }); + // Activating authenticated response and calling ngOnInit manually + mockUserResponse = { authenticated: true, username: 'Alice', email: 'alice@example.com' }; + app.ngOnInit(); + expect(app.isLoggedIn).toBe(true); expect(app.username).toBe('Alice'); expect(app.email).toBe('alice@example.com'); }); - it('should invoke OIDC login and logout flows', () => { + it('should invoke OIDC login and logout flows by redirecting the browser window', () => { const fixture = TestBed.createComponent(App); const app = fixture.componentInstance; - const loginSpy = vi.spyOn(mockOAuthService, 'initCodeFlow'); - const logoutSpy = vi.spyOn(mockOAuthService, 'revokeTokenAndLogout'); - app.login(); - expect(loginSpy).toHaveBeenCalled(); + expect(locationMock.href).toBe('/oauth2/authorization/oidc'); app.logout(); - expect(logoutSpy).toHaveBeenCalled(); + expect(locationMock.href).toBe('/logout'); }); it('should call hello API and set message on success', () => { @@ -111,22 +109,21 @@ describe('App', () => { const fixture = TestBed.createComponent(App); const app = fixture.componentInstance; - mockHttpClient.get = () => throwError(() => ({ statusText: 'Unauthorized' })); + mockHttpClient.get = (url: string) => { + if (url === '/api/user') { + return of(mockUserResponse); + } + return throwError(() => ({ statusText: 'Unauthorized' })); + }; app.callHelloApi(); expect(app.helloMessage()).toBe('Failed to connect to backend: Unauthorized'); }); it('should render logged in state when user is authenticated', async () => { + mockUserResponse = { authenticated: true, username: 'Alice', email: 'alice@example.com' }; const fixture = TestBed.createComponent(App); - // Log in - mockOAuthService.hasValidAccessToken = () => true; - mockOAuthService.getIdentityClaims = () => ({ - preferred_username: 'Alice', - email: 'alice@example.com' - }); - fixture.detectChanges(); await fixture.whenStable(); @@ -136,16 +133,10 @@ describe('App', () => { }); it('should render hello API response when API button is clicked', async () => { + mockUserResponse = { authenticated: true, username: 'Alice', email: 'alice@example.com' }; const fixture = TestBed.createComponent(App); const app = fixture.componentInstance; - // Log in - mockOAuthService.hasValidAccessToken = () => true; - mockOAuthService.getIdentityClaims = () => ({ - preferred_username: 'Alice', - email: 'alice@example.com' - }); - fixture.detectChanges(); await fixture.whenStable(); @@ -165,7 +156,7 @@ describe('App', () => { // Apartment CRUD tests it('should load apartments on init if authenticated', async () => { - mockOAuthService.hasValidAccessToken = () => true; + mockUserResponse = { authenticated: true, username: 'Alice', email: 'alice@example.com' }; const loadSpy = vi.spyOn(mockApartmentService, 'getApartments'); const fixture = TestBed.createComponent(App); const app = fixture.componentInstance; diff --git a/frontend/src/app/app.ts b/frontend/src/app/app.ts index efb6ed5..7366663 100644 --- a/frontend/src/app/app.ts +++ b/frontend/src/app/app.ts @@ -1,6 +1,5 @@ import { Component, signal, inject, OnInit, effect } from '@angular/core'; -import { OAuthService } from 'angular-oauth2-oidc'; -import { HttpClient, HttpHeaders } from '@angular/common/http'; +import { HttpClient } from '@angular/common/http'; import { ApartmentService, Apartment } from './apartment.service'; import { FormsModule } from '@angular/forms'; @@ -27,7 +26,10 @@ export class App implements OnInit { formDescription = ''; formError = ''; - private readonly oauthService = inject(OAuthService); + private readonly _isLoggedIn = signal(false); + private readonly _username = signal(''); + private readonly _email = signal(''); + private readonly http = inject(HttpClient); private readonly apartmentService = inject(ApartmentService); @@ -40,40 +42,49 @@ export class App implements OnInit { } ngOnInit() { - if (this.isLoggedIn) { - this.loadApartments(); - } + this.http.get<{authenticated: boolean, username?: string, email?: string}>('/api/user').subscribe({ + next: (user) => { + if (user.authenticated) { + this._isLoggedIn.set(true); + this._username.set(user.username || 'User'); + this._email.set(user.email || ''); + } else { + this._isLoggedIn.set(false); + this._username.set(''); + this._email.set(''); + } + }, + error: (err) => { + console.error('Error fetching user authentication status', err); + this._isLoggedIn.set(false); + this._username.set(''); + this._email.set(''); + } + }); } get isLoggedIn(): boolean { - return this.oauthService.hasValidAccessToken(); + return this._isLoggedIn(); } get username(): string { - const claims = this.oauthService.getIdentityClaims() as Record | null; - if (!claims) return ''; - return claims['preferred_username'] || claims['given_name'] || claims['name'] || 'User'; + return this._username(); } get email(): string { - const claims = this.oauthService.getIdentityClaims() as Record | null; - return claims ? claims['email'] : ''; + return this._email(); } login() { - this.oauthService.initCodeFlow(); + window.location.href = '/oauth2/authorization/oidc'; } logout() { - this.oauthService.revokeTokenAndLogout(); + window.location.href = '/logout'; } callHelloApi() { - const headers = new HttpHeaders({ - 'Authorization': 'Bearer ' + this.oauthService.getAccessToken() - }); - - this.http.get('/api/hello', { headers, responseType: 'text' }).subscribe({ + this.http.get('/api/hello', { responseType: 'text' }).subscribe({ next: (msg) => this.helloMessage.set(msg), error: (err) => { console.error(err); diff --git a/frontend/src/app/config.service.spec.ts b/frontend/src/app/config.service.spec.ts index 49fd46c..7854209 100644 --- a/frontend/src/app/config.service.spec.ts +++ b/frontend/src/app/config.service.spec.ts @@ -1,15 +1,10 @@ import { TestBed } from '@angular/core/testing'; import { ConfigService } from './config.service'; -import { vi } from 'vitest'; describe('ConfigService', () => { let service: ConfigService; beforeEach(() => { - // Reset env variables before each test - vi.stubEnv('OIDC_ISSUER_URI', ''); - vi.stubEnv('OIDC_CLIENT_ID', ''); - TestBed.configureTestingModule({ providers: [ConfigService] }); @@ -21,19 +16,6 @@ describe('ConfigService', () => { expect(service).toBeTruthy(); }); - it('should return default values when env variables are not set', () => { - expect(service.oidcIssuer).toBe('http://localhost:8090/realms/voidAuth'); - expect(service.clientId).toBe('lmnp-client'); - }); - - it('should return env values when env variables are set', () => { - vi.stubEnv('OIDC_ISSUER_URI', 'http://env-issuer'); - vi.stubEnv('OIDC_CLIENT_ID', 'env-client'); - - expect(service.oidcIssuer).toBe('http://env-issuer'); - expect(service.clientId).toBe('env-client'); - }); - it('should resolve loadConfig successfully', async () => { await expect(service.loadConfig()).resolves.toBeUndefined(); }); diff --git a/frontend/src/app/config.service.ts b/frontend/src/app/config.service.ts index 9d5172c..3f9f199 100644 --- a/frontend/src/app/config.service.ts +++ b/frontend/src/app/config.service.ts @@ -7,16 +7,4 @@ export class ConfigService { async loadConfig(): Promise { return Promise.resolve(); } - - get oidcIssuer(): string { - return import.meta.env['OIDC_ISSUER_URI'] || 'http://localhost:8090/realms/voidAuth'; - } - - get clientId(): string { - return import.meta.env['OIDC_CLIENT_ID'] || 'lmnp-client'; - } - - get clientSecret(): string { - return import.meta.env['OIDC_CLIENT_SECRET'] || 'lmnp-secret'; - } } diff --git a/frontend/src/env.d.ts b/frontend/src/env.d.ts deleted file mode 100644 index 7be7f8a..0000000 --- a/frontend/src/env.d.ts +++ /dev/null @@ -1,22 +0,0 @@ -// Define the type of the environment variables. -declare interface Env { - readonly NODE_ENV: string; - readonly OIDC_ISSUER_URI?: string; - readonly OIDC_CLIENT_ID?: string; - [key: string]: string | undefined; -} - -// Choose how to access the environment variables. -// Remove the unused options. - -// 1. Use import.meta.env.YOUR_ENV_VAR in your code. (conventional) -declare interface ImportMeta { - readonly env: Env; -} - -// 2. Use _NGX_ENV_.YOUR_ENV_VAR in your code. (customizable) -// You can modify the name of the variable in angular.json. -// ngxEnv: { -// define: '_NGX_ENV_', -// } -declare const _NGX_ENV_: Env;