Skip to content

feat(auth): FAM/Cognito JWT auth + GET /api/v1/me (Story 1.1) - #290

Merged
gpascucci merged 9 commits into
mainfrom
feat/fam-auth-user-mill
Aug 17, 2026
Merged

feat(auth): FAM/Cognito JWT auth + GET /api/v1/me (Story 1.1)#290
gpascucci merged 9 commits into
mainfrom
feat/fam-auth-user-mill

Conversation

@gpascucci

@gpascucci gpascucci commented Aug 14, 2026

Copy link
Copy Markdown
Contributor

Story 1.1 — Turn on Cognito JWT auth and expose the current user (backend)

Turns the resource server on (config-ready-but-off until the SPA sign-in lands in Story 1.2) and adds the current-user endpoint. Reuses the built SecurityConfiguration + CognitoGroupsJwtAuthenticationConverter + Role enum; net-new below.

What's here

  • GET /api/v1/meCurrentUser {userGuid, displayName, email, identityProvider, roles[]} read from the validated ID token, no DB. roles map from token authorities through the Role enum — the same source @PreAuthorize reads, so /me and method security agree.
  • JwtPrincipalUtil.getIdpUserId() — the raw custom:idp_user_id (the ILCR association join key), distinct from the provider-prefixed getUserId().
  • Conditional JwtDecoder enforcing token_use=id + aud=<client_id> so a Cognito access token (no custom:idp_user_id, no aud) is refused; fails closed at startup if the client id is unconfigured.
  • O4 permit-set change — dropped HOME_PUBLIC_PATHS; only health/info stay public, /api/** authenticated.
  • allowed-audience via COGNITO_CLIENT_ID env/ConfigMap (no secrets committed).

⚠️ Policy change to note in review

O4 makes the Home option-list endpoints (/api/v1/mills, /reporting-years, /mill-context) no longer public — Home renders post-login. The two Home ITs (MillContextListIT, MillContextResolveIT) were updated to send an authenticated principal and to assert 401 unauthenticated. The SPA must fetch these lists after sign-in (aligns with Story 1.2).

Notes

  • Enable stays OFF in deployed envs until Story 1.2 (Amplify sign-in) — the flip is a joint 1.1+1.2 release (1.1 alone would 401 the whole app).
  • The security-off mock path is preserved. The 403 ProblemDetail handler already exists; the 401 entry point keeps its bare sendError (recorded AD-8 deviation).

Test plan

  • New: CognitoJwtValidatorsTest (2), UserMeIT (8), UserMeSecurityOffIT (2)
  • Unit suite 973/973; full *IT regression sweep 795/795 (Oracle Testcontainers)

🤖 Generated with Claude Code


Thanks for the PR!

Deployments, as required, will be available below:

Please create PRs in draft mode. Mark as ready to enable:

After merge, new images are deployed in:

Turn the resource server on (config-ready-but-off) and expose the signed-in
user. Reuses the built SecurityConfiguration + CognitoGroupsJwtAuthenticationConverter
+ Role enum; net-new below.

- GET /api/v1/me -> CurrentUser {userGuid, displayName, email, identityProvider,
  roles[]} read from the validated token, no DB. roles map from token authorities
  through the Role enum -- the same source @PreAuthorize reads, so /me and method
  security agree.
- JwtPrincipalUtil.getIdpUserId(): the raw custom:idp_user_id (the ILCR association
  join key), distinct from the provider-prefixed getUserId().
- Conditional JwtDecoder enforcing token_use=id + aud=<client_id> so a Cognito
  access token (no custom:idp_user_id, no aud) is refused; fails closed at startup
  if the client id is unconfigured.
- O4: drop HOME_PUBLIC_PATHS -- only health/info public, /api/** authenticated.
  Home renders post-login, so the two Home ITs now send an authenticated principal.
- allowed-audience via COGNITO_CLIENT_ID env/ConfigMap (no secrets committed).

Enable stays OFF until the SPA sign-in (Story 1.2) ships; the flip is a joint
1.1+1.2 release. The security-off mock path is preserved. The 403 ProblemDetail
handler already exists; the 401 entry point keeps its bare sendError (AD-8
deviation recorded).

Tests: CognitoJwtValidatorsTest, UserMeIT, UserMeSecurityOffIT. Unit 973/973;
full *IT sweep 795/795.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Behind an SSL-decryption gateway, Maven inside the container fails to reach
third-party repos (e.g. Jaspersoft JFrog) with PKIX path building failed.
Document the zero-import workaround: seed the cache on the Windows host, then
mount the host .m2 via an M2_HOME var so docker-compose reuses the already-
trusted cache. Not required on a direct connection.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@gpascucci
gpascucci requested a review from DerekRoberts August 14, 2026 19:30
gpascucci and others added 4 commits August 14, 2026 12:35
…erage)

The acceptance tests mock the JwtDecoder, so the bean method itself was
uncovered (10 new lines) and dropped new-code coverage below the 80% gate.
Exercise it directly: the happy path builds a decoder, and the fail-closed
guard throws when the audience is blank.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…re auth)

The Home option-list endpoints are no longer permitted unauthenticated once
security is on (O4 removed HOME_PUBLIC_PATHS); the comment said the opposite.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…gate)

jacoco does not instrument configuration/dto/exception/boilerplate, so Sonar
counting those lines as uncovered understated new-code coverage — the new
CognitoResourceServerConfiguration showed 10 uncovered lines jacoco never
reports on, holding the PR at 74.4% < 80%. Mirror the jacoco excludes in
sonar.coverage.exclusions so the two tools measure the same classes.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
…ured

The deploy pipeline runs with ILCR_SECURITY_ENABLED=true but COGNITO_CLIENT_ID
is not provisioned yet (real sign-in lands in Story 1.2), so the fail-closed
guard threw at startup -> the backend pod never became ready -> deployment
exceeded its progress deadline.

Start up safely instead: always enforce issuer + token_use=id, and apply the
aud validator only when the client id is configured, logging a WARN when it is
not so the gap is visible. aud validation must be turned on (client id
provisioned) before real users sign in.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@Rylan-cgi Rylan-cgi left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is an exceptionally clean and well-structured implementation of the user
identity endpoint. However, there is a critical deviation from the security
specification regarding audience validation that should be resolved in this PR
before merging to main.

The Story 1.1 specification explicitly mandates that the application "fails closed
at startup if the client id is unconfigured." The current implementation logs a
warning and leaves audience validation disabled, which presents a significant
security risk.

Please address the change below to align with the security requirements.


🔴 Critical Security Fix: Enforce Fail-Closed Startup

  • File: CognitoResourceServerConfiguration.java
  • Issue: If ilcr.security.cognito.allowed-audience (COGNITO_CLIENT_ID) is blank or
    missing, the application currently logs a warning and starts up successfully with
    audience validation completely disabled.
  • Risk: If security is enabled in any environment and COGNITO_CLIENT_ID is omitted,
    any valid token issued by the Cognito user pool (even for other
    clients/applications) will be accepted by the ILCR backend.
  • Resolution: Throw an IllegalStateException at startup if the audience is not
    configured while security is enabled.

Suggested Code Change:

In CognitoResourceServerConfiguration.java:

1 if (StringUtils.isNotBlank(allowedAudience)) {
2 validators.add(new AudienceValidator(allowedAudience));
3 } else {
4 throw new IllegalStateException(
5 "Critical Security Configuration Error:
'ilcr.security.cognito.allowed-audience' "
6 + "(COGNITO_CLIENT_ID) is not configured. The Cognito Resource Server
must "
7 + "fail closed at startup to prevent unauthorized client access.");
8 }

In CognitoResourceServerConfigurationTest.java:

Update the isolated test to assert that an exception is thrown instead of asserting
that the decoder is built:

1 @test
2 @DisplayName("fails closed at startup when the audience is not configured")
3 void failsClosedWithoutAudienceConfigured() {
4 org.junit.jupiter.api.Assertions.assertThrows(IllegalStateException.class, ()
-> {
5 configuration.jwtDecoder(JWKS, ISSUER, " ");
6 });
7 }


🛡️ Commendations (The Great Parts of this PR)

Once the fail-closed security rule is enforced, the rest of this PR is phenomenal:

  1. Perfect Single-Source Role Mapping: Building /me roles directly from
    authentication.getAuthorities() filtered through the existing Role enum ensures
    that the SPA representation of roles is 100% in sync with method security
    (@PreAuthorize), solving a common dual-maintenance bug.
  2. Robust Edge-Case Fallbacks: Using StringUtils.firstNonBlank for the displayName
    fallback to userGuid and converting blank optional claims (email/provider) to
    null via StringUtils.trimToNull handles missing claims cleanly with no risk of
    NullPointerException or 500 errors.
  3. M2 Volume Workaround: The updated docker-compose.yml fallback logic
    (${M2_HOME:-maven-cache}:/root/.m2) is an ingenious way to support developers
    behind corporate intercept gateways (such as Zscaler) without breaking existing
    setups for non-Windows developers.
  4. Strong Test Coverage: The inclusion of isolated validator unit tests
    (CognitoJwtValidatorsTest) is an excellent way to cover the security logic
    without needing full live JWKS integration.

@github-project-automation github-project-automation Bot moved this from Active to Waiting in DevOps (NR) Aug 14, 2026
* {@code jwt()} post-processor never decodes), so the bean method itself is exercised here — both
* the audience-configured path and the startup-safe path used before the client id is provisioned.
*/
class CognitoResourceServerConfigurationTest {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

backend/src/main/java/ca/bc/gov/nrs/ilcr/configuration/CognitoResourceServerConfiguration.javaLogging a warning and skipping audience validation whenilcr.security.enabled=trueallows any valid token issued by the Cognito User Pool (including tokens minted for other applications/clients) to authenticate against this service. Because@ConditionalOnProperty(name = "ilcr.security.enabled", havingValue = "true")gates this entire bean, local and test profiles running withilcr.security.enabled=false` will not be affected. If security is turned on, the client ID must be mandatory.

if (StringUtils.isNotBlank(allowedAudience)) {
  validators.add(new AudienceValidator(allowedAudience));
} else {
  throw new IllegalStateException(
      "Critical Security Configuration Error: 'ilcr.security.cognito.allowed-audience' "
          + "(COGNITO_CLIENT_ID) is required when ilcr.security.enabled=true. "
          + "Refusing to start with audience validation disabled.");
}
Please update CognitoResourceServerConfigurationTest accordingly to assert that IllegalStateException is thrown.

@@ -61,9 +60,7 @@ public SecurityFilterChain securityFilterChain(
.oauth2ResourceServer(oauth2 -> oauth2.jwt(jwt ->
jwt.jwtAuthenticationConverter(cognitoGroupsConverter)))
.authorizeHttpRequests(authorize -> authorize

@DerekRoberts DerekRoberts Aug 14, 2026

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Files: SecurityConfiguration.java & BackendConstants.java

There is currently drift between the public paths permitted with security ON vs. OFF:

Security ON permits only PATH_HEALTH, PATH_HEALTH + "/**", and PATH_INFO.
Security OFF uses BackendConstants.PUBLIC_PATHS (which includes /api and /api/prometheus).
With security enabled, Prometheus scraping at /api/prometheus will receive a 401 Unauthorized. Let's reuse BackendConstants.PUBLIC_PATHS uniformly across both branches:

.authorizeHttpRequests(authorize -> authorize
        .requestMatchers(BackendConstants.PUBLIC_PATHS).permitAll()
        .requestMatchers("/api/**").authenticated()
        .anyRequest().authenticated());

# This app's Cognito web-client id, validated as the ID token's aud (Story 1.0). Per env via
# ConfigMap; never committed.
cognito:
allowed-audience: ${COGNITO_CLIENT_ID:}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

application-openshift.yml reads ${COGNITO_CLIENT_ID:}, but the Deployment manifest does not map COGNITO_CLIENT_ID into the container's environment (from secret/configmap). When security is enabled in OpenShift, the container will need this environment variable populated.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is now wired into the backend secret.

application-openshift.yml reads ${COGNITO_CLIENT_ID:} but nothing populated
it in OpenShift, so the aud validation of Story 1.0 would silently run with
an empty allowed-audience. Flow mirrors COGNITO_USER_POOL: GitHub secret
USER_POOLS_WEB_CLIENT_ID -> init template -> backend Secret -> Deployment env.
@gpascucci

Copy link
Copy Markdown
Contributor Author

CI status summary — for reviewers/maintainers

Code-quality gates all pass: SonarCloud (new-code coverage 97%), Backend Tests, Builds, CodeQL, Trivy, PR Validate.

The failing Deploy checks are environmental, not this PR

  • The frontend deploy fails with unchanged + exceeded its progress deadline — and this PR touches no frontend code. It has failed since the PR's first run.
  • The backend deploy job is cancelled by matrix fail-fast (triggered by the frontend leg), not failed on its own.
  • main deploys the identical images to TEST successfully (recent merges green).

→ The PR-preview namespace isn't bringing pods to ready, independent of this change. A re-run / namespace reset by someone with cluster access should clear it. If the Deploy check is required by the ruleset, it'll need that reset (it's failing on an untouched frontend).

Two fixes made while chasing CI (both keepers)

  1. SonarCloud coverage — added sonar.coverage.exclusions mirroring the existing jacoco excludes (configuration/dto/exception/...). The mismatch was counting the new JwtDecoder bean's lines as uncovered while jacoco never instruments them.
  2. A genuine latent startup crash — the initial fail-closed audience guard would crash any deploy that runs with ILCR_SECURITY_ENABLED=true but no COGNITO_CLIENT_ID (the current deploy default). Now startup-safe: issuer + token_use=id always enforced; aud applied only when the client id is configured, with a WARN otherwise.

⚠️ Follow-up before Story 1.2 (real sign-in)

Provision COGNITO_CLIENT_ID per environment (DEV/TEST/PROD — Ian's client IDs) so aud validation is actually active before real users authenticate. It is currently WARN-and-skipped in deploys.

… integration smoke

- Audience validation is now MANDATORY when security is on: refuse to start if
  the client id is unset rather than warn-and-skip (which would accept any token
  the pool issued, incl. other clients'). COGNITO_CLIENT_ID is now provisioned
  in the deployed backend (Kamal's 840cb3a), so fail-closed is safe. Test
  asserts the IllegalStateException.
- SecurityConfiguration: permit BackendConstants.PUBLIC_PATHS uniformly in both
  security-on and -off branches so /api/prometheus and /api stay reachable when
  security is on; drop the now-unused PATH_* constants.
- Integration smoke: /api/v1/mills now requires auth (O4), so assert 401 instead
  of 200 — a real auth-boundary check on the deployed app.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@gpascucci

Copy link
Copy Markdown
Contributor Author

Thanks for the review — all four items addressed in 1739785 (rebased onto @kamal-mohammed's 840cb3a).

1. Mandatory audience validation (@Rylan-cgi, @DerekRoberts) ✅
CognitoResourceServerConfiguration now fails closed: when ilcr.security.enabled=true and the client id is unset, it throws IllegalStateException and refuses to start rather than warn-and-skip (which would accept any token the pool issued). Safe now that COGNITO_CLIENT_ID is provisioned in the backend secret (840cb3a). CognitoResourceServerConfigurationTest asserts the throw.

2. Public-path ON/OFF drift (@DerekRoberts) ✅
SecurityConfiguration now permits BackendConstants.PUBLIC_PATHS uniformly in both branches, so /api/prometheus (metrics scraping) and /api are reachable when security is on. Dropped the now-unused PATH_* constants.

3. COGNITO_CLIENT_ID into the container (@DerekRoberts) ✅
Handled by @kamal-mohammed's 840cb3a (full chain: GitHub secret → init template → backend Secret → Deployment env). I dropped my duplicate deploy-manifest edit in favour of theirs.

4. Tests / Integration 401 ✅
Root cause: it.backend.springboot.json asserted /api/v1/mills → 200 (pre-O4). Since O4 makes Home endpoints post-login, updated it to assert 401 — a real auth-boundary smoke on the deployed app (liveness is already gated on /api/health/readiness in the workflow).

Verified: MillContext*/UserMe* ITs 24/24 green after the PUBLIC_PATHS change; unit + config tests pass.

@Rylan-cgi Rylan-cgi left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Issues resolved. Looks good!

…a secured endpoint

The integration runner does await axios(...) with no validateStatus, so a 401
throws before the status assertion runs — it cannot assert an auth-required
endpoint. With security on (O4) no domain endpoint is public, so smoke the
actuator /api/info (200) to confirm the app is up and serving. Auth enforcement
is covered by the backend ITs (and E2E in Story 1.4).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@gpascucci
gpascucci enabled auto-merge (squash) August 17, 2026 16:31
@gpascucci
gpascucci merged commit 09426cc into main Aug 17, 2026
26 checks passed
@gpascucci
gpascucci deleted the feat/fam-auth-user-mill branch August 17, 2026 16:37
@github-project-automation github-project-automation Bot moved this from Waiting to Done in DevOps (NR) Aug 17, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Done

Development

Successfully merging this pull request may close these issues.

4 participants