# Local Token SSO Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Replace database-coupled platform SSO with shared-secret JWT verification, local user/password synchronization, and direct system-JWT redirect. **Architecture:** The SSO boundary verifies a three-segment HS256 parent JWT with a configured shared secret and produces validated claims. A transactional local account service finds or creates `sys_user` by `userId`, synchronizes the profile, role, and BCrypt password hash, then the controller issues the existing system JWT and redirects it to the frontend. No runtime component connects to or queries a platform database. **Tech Stack:** Java 8, Spring Boot 2.7.18, Spring Security OAuth2 JOSE/Nimbus, MyBatis-Plus, H2 test profile, BCrypt. ## Global Constraints - Never connect to, query, or synchronize from the main platform database. - Verify the parent JWT with `platform-integration.token.link-secret-key`; never log or commit the shared secret, parent token, or password claim. - Require parent claims `userId`, `username`, `password`, and `roleid`. - Use `sys_user.id = userId`; map `roleid == 3` to `TEACHER`, all other values to `STUDENT`. - Every valid SSO login overwrites the local password hash with BCrypt of the parent `password` claim. - The application has one local datasource and `spring.datasource.hikari.minimum-idle: 2`. --- ## Task 1: Verify the parent Token without a platform repository **Files:** - Modify: `src/main/java/com/yau/digitalrmb/platformintegration/config/PlatformIntegrationProperties.java` - Modify: `src/main/java/com/yau/digitalrmb/platformintegration/application/VerifiedPlatformToken.java` - Modify: `src/main/java/com/yau/digitalrmb/platformintegration/application/PlatformTokenVerifier.java` - Delete: `src/main/java/com/yau/digitalrmb/platformintegration/application/PlatformIdentityRepository.java` - Delete: `src/main/java/com/yau/digitalrmb/platformintegration/infrastructure/JdbcPlatformIdentityRepository.java` - Delete: `src/main/java/com/yau/digitalrmb/platformintegration/domain/PlatformActor.java` - Delete: `src/main/java/com/yau/digitalrmb/platformintegration/domain/PlatformRole.java` - Test: `src/test/java/com/yau/digitalrmb/platformintegration/application/PlatformTokenVerifierTest.java` - Delete: `src/test/java/com/yau/digitalrmb/platformintegration/infrastructure/JdbcPlatformIdentityRepositoryTest.java` - Delete: `src/test/java/com/yau/digitalrmb/platformintegration/domain/PlatformRoleTest.java` **Interfaces:** - Produces `VerifiedPlatformToken verify(String rawToken)` with `userId()`, `username()`, `displayName()`, `rawPassword()`, `roleKey()`, and `optionalClaims()`. - [ ] **Step 1: Write failing verifier tests.** ```java @Test void acceptsSignedParentTokenWithoutDatabaseLookup() { VerifiedPlatformToken token = verifier.verify(parentToken(claims( "userId", 487L, "username", "tzs001", "password", "new-password", "roleid", 2L))); assertThat(token.userId()).isEqualTo(487L); assertThat(token.username()).isEqualTo("tzs001"); assertThat(token.rawPassword()).isEqualTo("new-password"); assertThat(token.roleKey()).isEqualTo("STUDENT"); } @Test void rejectsTamperedExpiredOrIncompleteToken() { assertThatThrownBy(() -> verifier.verify(tamperedToken)).isInstanceOf(PlatformTokenException.class); assertThatThrownBy(() -> verifier.verify(expiredToken)).isInstanceOf(PlatformTokenException.class); assertThatThrownBy(() -> verifier.verify(parentToken(claims("userId", 487L)))) .isInstanceOf(PlatformTokenException.class); } ``` - [ ] **Step 2: Run the verifier test to prove the old four-segment, repository-backed verifier fails.** Run: `mvn -B -Dtest=PlatformTokenVerifierTest test` Expected: FAIL because the old verifier requires `PlatformIdentityRepository`. - [ ] **Step 3: Implement the minimal shared-secret verifier.** Use the already available Spring Security JOSE support with a `SecretKeySpec(linkSecretKey.getBytes(UTF_8), "HmacSHA256")` to decode an HS256 JWT and validate its standard expiration. Reject blank or missing `userId`, `username`, `password`, or non-numeric `roleid`. Set `roleKey` to `TEACHER` only for `roleid == 3`; retain raw password only in-process and exclude it from logs, exceptions, and responses. Replace the old `Token` properties with: ```java public static class Token { @NotBlank private String linkSecretKey; } ``` - [ ] **Step 4: Run the verifier tests and commit.** Run: `mvn -B -Dtest=PlatformTokenVerifierTest test` Expected: PASS with no repository mock or platform datasource fixture. ```bash git add src/main/java/com/yau/digitalrmb/platformintegration src/test/java/com/yau/digitalrmb/platformintegration git commit -m "feat: verify platform jwt locally" ``` ## Task 2: Synchronize only the local user, role, snapshot, and password **Files:** - Create: `src/main/java/com/yau/digitalrmb/identity/application/LocalSsoAccountService.java` - Delete: `src/main/java/com/yau/digitalrmb/identity/application/PlatformIdentityProjectionService.java` - Delete: `src/main/java/com/yau/digitalrmb/identity/application/PlatformIdentitySyncJob.java` - Test: `src/test/java/com/yau/digitalrmb/identity/LocalSsoAccountServiceTest.java` - Delete: `src/test/java/com/yau/digitalrmb/identity/PlatformIdentityProjectionServiceTest.java` **Interfaces:** - Consumes `VerifiedPlatformToken`, `UserMapper`, `PlatformUserSnapshotMapper`, `PasswordEncoder`, and primary-datasource JDBC access. - Produces `LocalSsoAccount synchronize(VerifiedPlatformToken token)` with `userId()`, `username()`, `displayName()`, and `roleKey()`. - [ ] **Step 1: Write failing account synchronization tests.** ```java @Test void createsUserRoleSnapshotAndBcryptPassword() { LocalSsoAccount account = service.synchronize(studentToken(487L, "tzs001", "first-password")); assertThat(account.userId()).isEqualTo(487L); assertThat(passwordEncoder.matches("first-password", userMapper.selectById(487L).getPasswordHash())).isTrue(); assertThat(roleIdsFor(487L)).containsExactly(1002L); } @Test void refreshesPasswordAndRoleForExistingUser() { service.synchronize(studentToken(487L, "tzs001", "old-password")); service.synchronize(teacherToken(487L, "tzs002", "new-password")); assertThat(passwordEncoder.matches("new-password", userMapper.selectById(487L).getPasswordHash())).isTrue(); assertThat(passwordEncoder.matches("old-password", userMapper.selectById(487L).getPasswordHash())).isFalse(); assertThat(roleIdsFor(487L)).containsExactly(1001L); } ``` - [ ] **Step 2: Run the new test to prove it fails.** Run: `mvn -B -Dtest=LocalSsoAccountServiceTest test` Expected: FAIL because `LocalSsoAccountService` does not exist. - [ ] **Step 3: Implement the transactional local synchronizer.** Within one `@Transactional` method, load `sys_user` by `token.userId()`, create it with that ID when absent, and otherwise update it. In both branches set username, enabled status, and `passwordHash = passwordEncoder.encode(token.rawPassword())`. Upsert `platform_user_snapshot` from non-sensitive claims. Delete that user's `sys_user_role` rows and insert role ID `1001` for teachers or `1002` for students. Do not inject a named or secondary datasource. - [ ] **Step 4: Run the test and commit.** Run: `mvn -B -Dtest=LocalSsoAccountServiceTest test` Expected: PASS; repeated SSO has one user, one role row, and the current BCrypt password. ```bash git add src/main/java/com/yau/digitalrmb/identity src/test/java/com/yau/digitalrmb/identity git commit -m "feat: sync local account from platform token" ``` ## Task 3: Redirect the system JWT and remove exchange-code login **Files:** - Modify: `src/main/java/com/yau/digitalrmb/platformintegration/interfaces/PlatformSsoController.java` - Modify: `src/main/java/com/yau/digitalrmb/security/interfaces/AuthController.java` - Delete: `src/main/java/com/yau/digitalrmb/security/application/LoginExchangeCodeService.java` - Delete: `src/main/java/com/yau/digitalrmb/security/interfaces/ExchangeCodeRequest.java` - Delete: `src/main/java/com/yau/digitalrmb/security/interfaces/SessionResponse.java` - Test: `src/test/java/com/yau/digitalrmb/platformintegration/interfaces/PlatformSsoControllerTest.java` - Modify: `src/test/java/com/yau/digitalrmb/security/AuthControllerTest.java` - Delete: `src/test/java/com/yau/digitalrmb/security/LoginExchangeCodeServiceTest.java` **Interfaces:** - Consumes `PlatformTokenVerifier.verify`, `LocalSsoAccountService.synchronize`, and `JwtTokenService.issueFor(long, String, Set)`. - Produces `GET /api/v1/auth/sso?token=...` with 302 `Location: {frontendCallbackUrl}?token={urlEncodedSystemJwt}`. - [ ] **Step 1: Write failing MVC tests.** ```java mockMvc.perform(get("/api/v1/auth/sso").param("token", validParentToken)) .andExpect(status().isFound()) .andExpect(header().string("Location", startsWith("https://rmb.example.edu/sso-callback?token="))) .andExpect(header().string("Cache-Control", "no-store")) .andExpect(header().string("Referrer-Policy", "no-referrer")); mockMvc.perform(post("/api/v1/auth/login").contentType(MediaType.APPLICATION_JSON) .content("{\"username\":\"tzs001\",\"password\":\"new-password\"}")) .andExpect(status().isOk()); ``` - [ ] **Step 2: Run controller tests to prove the old exchange-code redirect fails.** Run: `mvn -B -Dtest=PlatformSsoControllerTest,AuthControllerTest test` Expected: FAIL because the old `Location` has `code=`. - [ ] **Step 3: Implement direct SSO redirect.** Verify the parent Token, synchronize the local account, issue the existing system JWT for the local ID and one role, URL-encode that system JWT, and redirect to `frontend.callback-url` with query key `token`. Preserve `Cache-Control: no-store` and `Referrer-Policy: no-referrer`. Remove `/session/exchange`; preserve `/login`, `/me`, and `/logout`. - [ ] **Step 4: Run controller tests and commit.** Run: `mvn -B -Dtest=PlatformSsoControllerTest,AuthControllerTest test` Expected: PASS; neither the incoming Token nor its password appears in the redirect. ```bash git add src/main/java/com/yau/digitalrmb/platformintegration/interfaces src/main/java/com/yau/digitalrmb/security src/test/java/com/yau/digitalrmb/platformintegration/interfaces src/test/java/com/yau/digitalrmb/security git commit -m "feat: redirect local jwt after sso" ``` ## Task 4: Remove the second datasource and verify Java 8 startup **Files:** - Modify: `src/main/resources/application.yml` - Modify: `src/main/resources/application-dev.yml` - Modify: `src/main/resources/application-local.yml` - Modify: `src/main/resources/application-test.yml` - Modify: `src/main/resources/schema.sql` - Delete: `src/main/java/com/yau/digitalrmb/platformintegration/infrastructure/PlatformReadOnlyDataSourceConfig.java` - Delete: `src/main/java/com/yau/digitalrmb/platformintegration/interfaces/CasAuthenticationController.java` - Delete: `src/main/java/com/yau/digitalrmb/platformintegration/application/CasTicketValidator.java` - Delete: `src/test/java/com/yau/digitalrmb/platformintegration/infrastructure/PlatformReadOnlyDataSourceConfigTest.java` - Delete: `src/test/java/com/yau/digitalrmb/platformintegration/config/PlatformIntegrationPropertiesTest.java` - Modify: `src/test/java/com/yau/digitalrmb/ApplicationContextTest.java` - Modify: `README.md` **Interfaces:** - Produces an application context with primary `dataSource` only; no `platformReadOnlyDataSource` or `platformNamedParameterJdbcTemplate` bean. - [ ] **Step 1: Write the failing one-datasource context test.** ```java @Autowired ApplicationContext context; @Test void startsWithOnlyLocalDatasource() { assertThat(context.containsBean("dataSource")).isTrue(); assertThat(context.containsBean("platformReadOnlyDataSource")).isFalse(); assertThat(context.containsBean("platformNamedParameterJdbcTemplate")).isFalse(); } ``` - [ ] **Step 2: Run it and verify it fails while the platform datasource exists.** Run: `mvn -B -Dtest=ApplicationContextTest test` Expected: FAIL because `platformReadOnlyDataSource` exists. - [ ] **Step 3: Remove platform database, CAS, and sync configuration.** Delete secondary datasource/CAS/sync properties from all profiles and remove their classes/tests. Retain `platform_user_snapshot` and `auth_login_exchange_code` tables to avoid destructive database migration, but remove executable references to exchange codes. Configure only `platform-integration.token.link-secret-key: ${DIGITAL_RMB_PLATFORM_LINK_SECRET_KEY}` and `platform-integration.frontend.callback-url`; set the primary Hikari `minimum-idle` to `2`. - [ ] **Step 4: Run context/full Java 8 tests and build.** Run: `mvn -B -Dtest=ApplicationContextTest test` Expected: PASS; only `dataSource` is built. Run: `mvn -B test` Expected: PASS under Java 8. Run: `mvn -B -DskipTests package` Expected: BUILD SUCCESS with Java 8. - [ ] **Step 5: Update documentation, verify removed references, and commit.** Document `DIGITAL_RMB_PLATFORM_LINK_SECRET_KEY`, `DIGITAL_RMB_FRONTEND_CALLBACK_URL`, required claims, direct redirect, and password refresh. Never include a real secret or password. Run: `rg -n "DIGITAL_RMB_PLATFORM_DB|platformReadOnlyDataSource|platformNamedParameterJdbcTemplate|LoginExchangeCodeService|/session/exchange" src README.md` Expected: no executable source reference. ```bash git add src/main/java src/main/resources src/test/java README.md git commit -m "refactor: remove platform database dependency" ```