From 5ae798099e5e74200776eb5d6ceaf9d7f3128d5b Mon Sep 17 00:00:00 2001 From: chenyuan Date: Tue, 4 Aug 2026 10:01:40 +0800 Subject: [PATCH] docs: plan local token sso implementation --- ...26-08-04-local-token-sso-implementation.md | 262 ++++++++++++++++++ 1 file changed, 262 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-04-local-token-sso-implementation.md diff --git a/docs/superpowers/plans/2026-08-04-local-token-sso-implementation.md b/docs/superpowers/plans/2026-08-04-local-token-sso-implementation.md new file mode 100644 index 0000000..1979886 --- /dev/null +++ b/docs/superpowers/plans/2026-08-04-local-token-sso-implementation.md @@ -0,0 +1,262 @@ +# 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" +```