docs: plan local token sso implementation
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…
Reference in New Issue