fix(login): honor SSO nosplash for unauthenticated visitors - #5678
Conversation
The /login page only auto-redirected to the identity provider when a valid session already existed. With SSO configured using the 'nosplash' option, a first visit with no session stopped on the login form showing a Sign In button instead of redirecting straight through to the IdP. Add an unauthenticated nosplash path in LoginPageComponent.ngOnInit that mirrors the existing logged-in redirect: when there is no valid session but sessionData.ssoOptions contains 'nosplash' (and no SSO_Message error is present), trigger the SSO login flow. Includes unit tests covering the redirect and the negative (non-SSO) case. Assisted-by: OpenCode (AI agent); reviewed by a human contributor per Stratos AI contribution guidance. Signed-off-by: Chris Weibel <christopher.weibel@gsa.gov>
|
Can you fix the spec test or should I https://github.com/cloudfoundry/stratos/actions/runs/29917476492/job/88914655817?pr=5678 |
The login-page nosplash spec imported InvalidSession/ResetAuth via a deep relative path into the store package source, which broke Vite import resolution of the transitive @ngrx/store reference in CI (worked locally but failed on the runner). Export InvalidSession and ResetAuth from the @stratosui/store public API and import them (and other store symbols) via the alias, matching every other core spec. No production behavior change. Signed-off-by: Chris Weibel <christopher.weibel@gsa.gov>
…henticated-autologin # Conflicts: # src/frontend/packages/store/src/public-api.ts
I got it, pulling out the ngrx bits and refactoring |
norman-abramovitz
left a comment
There was a problem hiding this comment.
Traced this through the redirect-loop protection — there's a gap worth closing before merge, plus a readability tidy on the new filter.
Redirect-loop guard. The new unauthenticated path fires doSSOLoginReactive() on the !loggedIn && !sessionData.valid state — exactly the state a browser lands in when it comes back from an SSO round-trip that didn't establish a session. That path has no loop protection:
doSSOLoginReactive()does an unconditional hard redirect (window.open('/pp/v1/auth/sso_login…', '_self')) and never touchesredirectAttempts.- The counter guard (
redirectAttempts++/ bail overMAX_REDIRECT_ATTEMPTS) only lives inhandleSuccessfulLogin, which the SSO branch returns before reaching. take(1)doesn't help across page loads — each IdP round-trip is a full navigation, so we return to a fresh component instance andtake(1)resets. ThesessionStorageredirectAttemptscounter is the only thing that survives, and this path neither reads nor bumps it.
Failure case: the IdP returns to /login with no Stratos session and no SSO_Message (silent auth failure, cookie/config issue, or the back button) → the filter matches again → redirect again, with nothing to break it. The !queryParamMap().SSO_Message guard only catches the error-message case. Given MAX_REDIRECT_ATTEMPTS = 2 already exists here, these round-trips evidently do fail to seat a session sometimes.
The existing logged-in nosplash branch is counter-free too, but it's inherently safe because it only fires on an already-valid session; the new path drops that precondition without replacing the guard.
One wrinkle if you add the counter check: a pure-SSO success never runs clearRedirectAttempts() (that's inside handleSuccessfulLogin, which the SSO branch skips), so the counter wouldn't reset on a good login and a later genuine visit could inherit a stale count. Worth deciding where the reset lives — probably clear it once the session comes back valid. This is the part that really wants a test in a live nosplash SSO setup, since the round-trip can't be reproduced here.
Rest looks correct — the new filter is mutually exclusive with the logged-in branch (no double-fire), queryParamMap() at emit time is fine for a load-time param, and the tests assert real behavior. See the inline suggestion for the guard plus a readability pass on the predicate.
| this.auth$.pipe( | ||
| filter(auth => | ||
| !auth.loggingIn && | ||
| !auth.verifying && | ||
| !auth.loggedIn && | ||
| !!auth.sessionData && | ||
| !auth.sessionData.valid && | ||
| !!auth.sessionData.ssoOptions && | ||
| auth.sessionData.ssoOptions.indexOf('nosplash') >= 0 && | ||
| !queryParamMap().SSO_Message | ||
| ), | ||
| take(1), // Only trigger once on init | ||
| switchMap(() => this.appReady$) // Wait for app to be stable before navigating | ||
| ).subscribe(() => { | ||
| this.doSSOLoginReactive().subscribe(); | ||
| }); |
There was a problem hiding this comment.
The predicate is hard to scan as a flat && chain. Naming each condition makes the rule read at a glance, and it's the natural place to fold in the loop guard (underRedirectCap in the filter + redirectAttempts++ before the redirect). This also swaps .indexOf('nosplash') >= 0 for .includes(...) and uses ?. to drop the !!sessionData noise:
| this.auth$.pipe( | |
| filter(auth => | |
| !auth.loggingIn && | |
| !auth.verifying && | |
| !auth.loggedIn && | |
| !!auth.sessionData && | |
| !auth.sessionData.valid && | |
| !!auth.sessionData.ssoOptions && | |
| auth.sessionData.ssoOptions.indexOf('nosplash') >= 0 && | |
| !queryParamMap().SSO_Message | |
| ), | |
| take(1), // Only trigger once on init | |
| switchMap(() => this.appReady$) // Wait for app to be stable before navigating | |
| ).subscribe(() => { | |
| this.doSSOLoginReactive().subscribe(); | |
| }); | |
| this.auth$.pipe( | |
| filter(auth => { | |
| const idle = !auth.loggingIn && !auth.verifying; | |
| const noValidSession = !auth.loggedIn && !!auth.sessionData && !auth.sessionData.valid; | |
| const nosplash = !!auth.sessionData?.ssoOptions?.includes('nosplash'); | |
| const showingError = !!queryParamMap().SSO_Message; | |
| const underRedirectCap = this.redirectAttempts <= this.MAX_REDIRECT_ATTEMPTS; | |
| return idle && noValidSession && nosplash && !showingError && underRedirectCap; | |
| }), | |
| take(1), // Only trigger once per component instance | |
| switchMap(() => this.appReady$) // Wait for app to be stable before navigating | |
| ).subscribe(() => { | |
| this.redirectAttempts++; // survives the IdP round-trip via sessionStorage; breaks a silent redirect loop | |
| this.doSSOLoginReactive().subscribe(); | |
| }); |
The guard values still want a test against a real nosplash IdP (loop break + counter reset on success).
If you'd rather, the whole predicate could move into a named method — reads even cleaner, and it lets you dedupe against the existing logged-in nosplash check above (lines 240–242 run the same nosplash + !SSO_Message test):
filter(auth => this.shouldAutoRedirectNosplash(auth)), private shouldAutoRedirectNosplash(auth: AuthState): boolean {
const idle = !auth.loggingIn && !auth.verifying;
const noValidSession = !auth.loggedIn && !!auth.sessionData && !auth.sessionData.valid;
const nosplash = !!auth.sessionData?.ssoOptions?.includes('nosplash');
return idle && noValidSession && nosplash
&& !queryParamMap().SSO_Message
&& this.redirectAttempts <= this.MAX_REDIRECT_ATTEMPTS;
}That version touches the working logged-in branch if you dedupe, so it's a judgment call — the inline suggestion above keeps the change scoped to your new code.
Address review feedback on the unauthenticated SSO nosplash auto-redirect: - The new path fired on the same !loggedIn/!valid state a browser lands in when returning from an SSO round-trip that did not seat a session, with no loop protection. take(1) does not survive full-navigation IdP round-trips, so a silent auth failure (cookie/config issue, back button) could loop. - Gate the redirect on the persisted redirectAttempts counter (sessionStorage, MAX_REDIRECT_ATTEMPTS) and bump it before redirecting, so a silent loop is broken after the cap. - Reset the counter once a valid session is seen (the pure-SSO success path skips handleSuccessfulLogin, where the reset previously lived) so a later genuine visit does not inherit a stale count. - Extract the predicate into shouldAutoRedirectNosplash() for readability, using optional chaining + Array.includes(). - Add specs for: SSO_Message-present (no redirect), redirect-cap exceeded (no redirect), and counter bump on the happy path. Live loop-break / counter-reset behavior still wants validation against a real nosplash SSO IdP, since the full-navigation round-trip cannot be reproduced in unit tests. Signed-off-by: Chris Weibel <christopher.weibel@gsa.gov>
|
@norman-abramovitz thanks for the careful trace — you're right that the unauthenticated path dropped the loop protection the logged-in branch got for free. Pushed a fix (42775c9) addressing each point: Redirect-loop guard. The new path now gates on the persisted this.auth$.pipe(
filter(auth => this.shouldAutoRedirectNosplash(auth)),
take(1),
switchMap(() => this.appReady$)
).subscribe(() => {
this.redirectAttempts++; // survives the IdP round-trip via sessionStorage; breaks a silent loop
this.doSSOLoginReactive().subscribe();
});Since Counter reset. Good catch on the pure-SSO success skipping // A valid session was established... clear the nosplash redirect counter
this.clearRedirectAttempts();
await this.handleSuccessfulLogin(auth);Readability. Moved the predicate into a named method as you suggested, with private shouldAutoRedirectNosplash(auth: AuthState): boolean {
const idle = !auth.loggingIn && !auth.verifying;
const noValidSession = !auth.loggedIn && !!auth.sessionData && !auth.sessionData.valid;
const nosplash = !!auth.sessionData?.ssoOptions?.includes('nosplash');
return idle && noValidSession && nosplash
&& !queryParamMap().SSO_Message
&& this.redirectAttempts <= this.MAX_REDIRECT_ATTEMPTS;
}I kept it scoped to the new code and did not dedupe against the logged-in nosplash check (240–242) — per your note that touching the working branch is a judgment call, I left it untouched to keep the blast radius small. Happy to dedupe in a follow-up if you'd prefer. Tests. Added specs for the guard: Live validation. Agreed the loop-break + counter-reset really wants a real nosplash SSO IdP — the full-navigation round-trip can't be reproduced in unit tests. I'll validate against our dev cloud.gov deployment and report back here. |
…henticated-autologin # Conflicts: # src/frontend/packages/core/src/features/login/login-page/login-page.component.spec.ts
build/create-checksums.sh globbed '*.tar.gz *.zip' unconditionally. A
CF-only release ('make release cf') stages only a .zip into dist/release
(the .tar.gz archives are GitHub-release artifacts), so under
'set -euo pipefail' the unmatched *.tar.gz glob stayed literal, sha256sum
returned non-zero, and pipefail failed the _release.checksums make target.
Use nullglob + an explicit archive list so non-matching patterns expand to
nothing and only existing archives are checksummed. Verified a CF-only
layout now generates and verifies SHA256SUMS and exits 0.
Signed-off-by: Chris Weibel <christopher.weibel@gsa.gov>
|
Heads-up on an out-of-scope build fix I folded into this branch to unblock CI/dev deployment (happy to split into its own PR if you'd prefer):
Fix uses It's unrelated to the login change, but it blocked building this branch for our dev validation, so it's included here. Let me know if you'd like it pulled out into a separate PR. |
Problem
The
/loginpage only auto-redirects to the identity provider when a valid session already exists. With SSO configured using thenosplashoption, a first visit with no session stops on the login form showing a Sign In button, instead of redirecting straight through to the IdP.The existing
nosplashhandling lives inside the "already logged in" branch ofLoginPageComponent.ngOnInit(guarded byauth.loggedIn && sessionData.valid), so the unauthenticated first-visit case is never covered.Change
Add an unauthenticated
nosplashpath inLoginPageComponent.ngOnInitthat mirrors the existing logged-in redirect: when there is no valid session butsessionData.ssoOptionscontainsnosplash(and noSSO_Messageerror is present), it triggers the existing SSO login flow (doSSOLoginReactive()), sending the user straight to the IdP.filter,take,switchMap,queryParamMap,appReady$,doSSOLoginReactive) — no new dependencies.!queryParamMap().SSO_Messageguard preserves error-message display and avoids redirect loops.take(1)+appReady$match the sibling subscription's pattern.Tests
Added unit tests in
login-page.component.spec.ts:ssoOptions: 'nosplash,logout'→doSSOLoginReactivecalled once.doSSOLoginReactivenot called (login form shown).Verified locally:
vitest run --project=coreon the login specs → passing.eslintclean on the changed files.Verification transcript
Notes
coresuite failure inStratosBrandingService > resetTheme()(test localStorage mock returns''vs. assertednull). Filed separately as Test failure: StratosBrandingService resetTheme() spec expects null but test localStorage mock returns '' #5677 — not touched by this PR.Implemented with AI assistance (OpenCode); reviewed by a human contributor per Stratos AI contribution guidance. Commit is DCO signed-off.