13 KiB
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, androleid. - Use
sys_user.id = userId; maproleid == 3toTEACHER, all other values toSTUDENT. - Every valid SSO login overwrites the local password hash with BCrypt of the parent
passwordclaim. - 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)withuserId(),username(),displayName(),rawPassword(),roleKey(), andoptionalClaims(). -
Step 1: Write failing verifier tests.
@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:
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.
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)withuserId(),username(),displayName(), androleKey(). -
Step 1: Write failing account synchronization tests.
@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.
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, andJwtTokenService.issueFor(long, String, Set<String>). -
Produces
GET /api/v1/auth/sso?token=...with 302Location: {frontendCallbackUrl}?token={urlEncodedSystemJwt}. -
Step 1: Write failing MVC tests.
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.
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
dataSourceonly; noplatformReadOnlyDataSourceorplatformNamedParameterJdbcTemplatebean. -
Step 1: Write the failing one-datasource context test.
@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.
git add src/main/java src/main/resources src/test/java README.md
git commit -m "refactor: remove platform database dependency"