docs: plan local token sso implementation

master
chenyuan 4 weeks ago
parent 1d1b1196e5
commit 5ae798099e

@ -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<String>)`.
- 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"
```
Loading…
Cancel
Save