Skip to content

Commit f721075

Browse files
ZCU-PUB/fix: reload redirect loop after Shibboleth login (absolute reload URL, no SSR redirect) (#1384)
* ZCU-PUB/test: reload redirect must be absolute and must not fire during SSR Replicates the redirect loop after a Shibboleth login: the relative 'reload/<timestamp>' URL is resolved against the current URL (e.g. /bitstreams/<uuid>/download), producing growing /bitstreams/<uuid>/reload/reload/... URLs, because during SSR the redirect repeats on every request (the dsRedirectUrl cookie cannot be cleared server-side - ServerCookieService.remove is a no-op). Also stops static-page.component.spec from mutating the shared environment object (it replaced environment.ui, breaking later specs reading environment.ui.nameSpace). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * ZCU-PUB/fix: absolute reload URL and no post-login hard redirect during SSR Fixes the redirect loop after a Shibboleth login: - navigateToRedirectUrl() and refreshAfterChangeLanguage() build an absolute, ui.nameSpace-aware /reload/<ts> URL. A relative 'reload/<ts>' URL in the Location header is resolved against the request URL (e.g. /bitstreams/<uuid>/download after an external login), producing nested /bitstreams/<uuid>/reload/... URLs which never match the top-level 'reload/:rnd' route. navigateToRedirectUrl() also skips the redirect when the current path already is the reload page. - authenticatedSuccess$ dispatches RedirectAfterLoginSuccess only in the browser. The server cannot clear the dsRedirectUrl cookie (ServerCookieService.set/remove are no-ops), so a hard redirect during SSR repeats on every request - one 'reload/' segment per 302 hop - until the browser aborts at its redirect limit. - ServerHardRedirectService.redirect() never emits a relative Location header. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
1 parent 7c15413 commit f721075

9 files changed

Lines changed: 177 additions & 19 deletions

src/app/core/auth/auth.effects.spec.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { fakeAsync, TestBed, tick } from '@angular/core/testing';
2+
import { NgZone } from '@angular/core';
23

4+
import { Actions } from '@ngrx/effects';
35
import { provideMockActions } from '@ngrx/effects/testing';
46
import { Store, StoreModule } from '@ngrx/store';
57
import { MockStore, provideMockStore } from '@ngrx/store/testing';
@@ -17,6 +19,7 @@ import {
1719
CheckAuthenticationTokenCookieAction,
1820
LogOutErrorAction,
1921
LogOutSuccessAction,
22+
RedirectAfterLoginSuccessAction,
2023
RefreshTokenErrorAction,
2124
RefreshTokenSuccessAction,
2225
RetrieveAuthenticatedEpersonAction,
@@ -176,6 +179,52 @@ describe('AuthEffects', () => {
176179
done();
177180
});
178181

182+
describe('when a redirect url is set', () => {
183+
const redirectUrl = '/bitstreams/eb0ca4e9-3e00-4c1f-8b19-a2c9f4f01234/download';
184+
185+
beforeEach(() => {
186+
spyOn((authEffects as any).authService, 'storeToken');
187+
authServiceStub.setRedirectUrl(redirectUrl);
188+
actions = hot('--a-', {
189+
a: {
190+
type: AuthActionTypes.AUTHENTICATED_SUCCESS, payload: {
191+
authenticated: true,
192+
authToken: token,
193+
userHref: EPersonMock._links.self.href
194+
}
195+
}
196+
});
197+
});
198+
199+
afterEach(() => {
200+
authServiceStub.setRedirectUrl(undefined);
201+
});
202+
203+
it('should return a REDIRECT_AFTER_LOGIN_SUCCESS action in the browser', () => {
204+
const expected = cold('--b-', { b: new RedirectAfterLoginSuccessAction(redirectUrl) });
205+
206+
expect(authEffects.authenticatedSuccess$).toBeObservable(expected);
207+
});
208+
209+
it('should return a RETRIEVE_AUTHENTICATED_EPERSON action during server-side rendering', () => {
210+
// The server cannot clear the redirect cookie (ServerCookieService.set/remove are no-ops),
211+
// so a hard redirect during SSR would repeat on every request and create a redirect loop.
212+
// The redirect must be left to the browser.
213+
const serverAuthEffects: AuthEffects = new (AuthEffects as any)(
214+
TestBed.inject(Actions),
215+
TestBed.inject(NgZone),
216+
authorizationService,
217+
authServiceStub as any,
218+
TestBed.inject(Store) as any,
219+
'server'
220+
);
221+
222+
const expected = cold('--b-', { b: new RetrieveAuthenticatedEpersonAction(EPersonMock._links.self.href) });
223+
224+
expect(serverAuthEffects.authenticatedSuccess$).toBeObservable(expected);
225+
});
226+
});
227+
179228
});
180229

181230
describe('checkToken$', () => {

src/app/core/auth/auth.effects.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
1-
import { Injectable, NgZone } from '@angular/core';
1+
import { Inject, Injectable, NgZone, PLATFORM_ID } from '@angular/core';
2+
import { isPlatformBrowser } from '@angular/common';
23

34
import {
45
asyncScheduler,
@@ -100,7 +101,11 @@ export class AuthEffects {
100101
map((redirectUrl: string) => [action, redirectUrl])
101102
)),
102103
map(([action, redirectUrl]: [AuthenticatedSuccessAction, string]) => {
103-
if (hasValue(redirectUrl)) {
104+
// Perform the redirect only in the browser: the server cannot clear the redirect cookie
105+
// (ServerCookieService.set/remove are no-ops), so a hard redirect during SSR would repeat
106+
// on every request and create a redirect loop.
107+
// The browser re-runs this effect after hydration and performs the redirect itself.
108+
if (hasValue(redirectUrl) && isPlatformBrowser(this.platformId)) {
104109
return new RedirectAfterLoginSuccessAction(redirectUrl);
105110
} else {
106111
return new RetrieveAuthenticatedEpersonAction(action.payload.userHref);
@@ -287,6 +292,7 @@ export class AuthEffects {
287292
private zone: NgZone,
288293
private authorizationsService: AuthorizationDataService,
289294
private authService: AuthService,
290-
private store: Store<AppState>) {
295+
private store: Store<AppState>,
296+
@Inject(PLATFORM_ID) private platformId: any) {
291297
}
292298
}

src/app/core/auth/auth.service.spec.ts

Lines changed: 21 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ import { NotificationsServiceStub } from '../../shared/testing/notifications-ser
3434
import { SetUserAsIdleAction, UnsetUserAsIdleAction } from './auth.actions';
3535
import { SpecialGroupDataMock, SpecialGroupDataMock$ } from '../../shared/testing/special-group.mock';
3636
import { cold } from 'jasmine-marbles';
37+
import { environment } from '../../../environments/environment';
3738

3839
describe('AuthService test', () => {
3940

@@ -103,7 +104,7 @@ describe('AuthService test', () => {
103104
linkService = {
104105
resolveLinks: {}
105106
};
106-
hardRedirectService = jasmine.createSpyObj('hardRedirectService', ['redirect']);
107+
hardRedirectService = jasmine.createSpyObj('hardRedirectService', ['redirect', 'getCurrentRoute']);
107108
spyOn(linkService, 'resolveLinks').and.returnValue({ authenticated: true, eperson: observableOf({ payload: {} }) });
108109

109110
}
@@ -374,28 +375,39 @@ describe('AuthService test', () => {
374375
expect(storage.remove).toHaveBeenCalled();
375376
});
376377

377-
it('should redirect to reload with redirect url', () => {
378+
// The reload URL must be absolute (nameSpace-aware): a relative 'reload/...' URL is resolved
379+
// against the current URL - e.g. against /bitstreams/<uuid>/download after an external
380+
// (Shibboleth) login - producing invalid nested URLs like /bitstreams/<uuid>/reload/reload/...
381+
const reloadPrefix = environment.ui.nameSpace.replace(/\/$/, '') + '/reload/';
382+
383+
it('should redirect to the absolute reload URL with redirect url', () => {
378384
authService.navigateToRedirectUrl('/collection/123');
379385
// Reload with redirect URL set to /collection/123
380-
expect(hardRedirectService.redirect).toHaveBeenCalledWith(jasmine.stringMatching(new RegExp('reload/[0-9]*\\?redirect=' + encodeURIComponent('/collection/123'))));
386+
expect(hardRedirectService.redirect).toHaveBeenCalledWith(jasmine.stringMatching(new RegExp('^' + reloadPrefix + '[0-9]+\\?redirect=' + encodeURIComponent('/collection/123') + '$')));
381387
});
382388

383-
it('should redirect to reload with /home', () => {
389+
it('should redirect to the absolute reload URL with /home', () => {
384390
authService.navigateToRedirectUrl('/home');
385391
// Reload with redirect URL set to /home
386-
expect(hardRedirectService.redirect).toHaveBeenCalledWith(jasmine.stringMatching(new RegExp('reload/[0-9]*\\?redirect=' + encodeURIComponent('/home'))));
392+
expect(hardRedirectService.redirect).toHaveBeenCalledWith(jasmine.stringMatching(new RegExp('^' + reloadPrefix + '[0-9]+\\?redirect=' + encodeURIComponent('/home') + '$')));
387393
});
388394

389-
it('should redirect to regular reload and not to /login', () => {
395+
it('should redirect to the absolute reload URL and not to /login', () => {
390396
authService.navigateToRedirectUrl('/login');
391397
// Reload without a redirect URL
392-
expect(hardRedirectService.redirect).toHaveBeenCalledWith(jasmine.stringMatching(new RegExp('reload/[0-9]*(?!\\?)$')));
398+
expect(hardRedirectService.redirect).toHaveBeenCalledWith(jasmine.stringMatching(new RegExp('^' + reloadPrefix + '[0-9]+$')));
393399
});
394400

395-
it('should redirect to regular reload when no redirect url is found', () => {
401+
it('should redirect to the absolute reload URL when no redirect url is found', () => {
396402
authService.navigateToRedirectUrl(undefined);
397403
// Reload without a redirect URL
398-
expect(hardRedirectService.redirect).toHaveBeenCalledWith(jasmine.stringMatching(new RegExp('reload/[0-9]*(?!\\?)$')));
404+
expect(hardRedirectService.redirect).toHaveBeenCalledWith(jasmine.stringMatching(new RegExp('^' + reloadPrefix + '[0-9]+$')));
405+
});
406+
407+
it('should not redirect again when the current route is already the reload page', () => {
408+
hardRedirectService.getCurrentRoute.and.returnValue(reloadPrefix + '123456789?redirect=' + encodeURIComponent('/home'));
409+
authService.navigateToRedirectUrl('/collection/123');
410+
expect(hardRedirectService.redirect).not.toHaveBeenCalled();
399411
});
400412

401413
describe('impersonate', () => {

src/app/core/auth/auth.service.ts

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -487,9 +487,21 @@ export class AuthService {
487487
* @param redirectUrl
488488
*/
489489
public navigateToRedirectUrl(redirectUrl: string) {
490+
// Don't do redirect if the current page already is the reload page,
491+
// otherwise the reload could be repeated indefinitely
492+
// (only the path is checked - a query string could legitimately contain '/reload/')
493+
const currentRoute = this.hardRedirectService.getCurrentRoute();
494+
if (hasValue(currentRoute) && currentRoute.split('?')[0].includes('/reload/')) {
495+
return;
496+
}
490497
// Don't do redirect if already on reload url
491498
if (!hasValue(redirectUrl) || !redirectUrl.includes('reload/')) {
492-
let url = `reload/${new Date().getTime()}`;
499+
// The reload URL must be absolute (nameSpace-aware). A relative 'reload/...' URL is resolved
500+
// against the current URL - e.g. against /bitstreams/<uuid>/download after an external
501+
// (Shibboleth) login - and produces invalid nested URLs like /bitstreams/<uuid>/reload/...
502+
// which never match the 'reload/:rnd' route.
503+
const nameSpace = (environment.ui.nameSpace || '').replace(/\/$/, '');
504+
let url = `${nameSpace}/reload/${new Date().getTime()}`;
493505
if (isNotEmpty(redirectUrl) && !redirectUrl.startsWith(LOGIN_ROUTE)) {
494506
url += `?redirect=${encodeURIComponent(redirectUrl)}`;
495507
}

src/app/core/locale/locale.service.spec.ts

Lines changed: 37 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
1-
import { TestBed, waitForAsync } from '@angular/core/testing';
1+
import { fakeAsync, TestBed, tick, waitForAsync } from '@angular/core/testing';
22

33
import { TranslateLoader, TranslateModule, TranslateService } from '@ngx-translate/core';
4+
import { of } from 'rxjs';
5+
6+
import { environment } from '../../../environments/environment';
47

58
import { CookieService } from '../services/cookie.service';
69
import { CookieServiceMock } from '../../shared/mocks/cookie.service.mock';
@@ -147,4 +150,37 @@ describe('LocaleService test suite', () => {
147150
expect(service.getLanguageCodeList).toHaveBeenCalled();
148151
});
149152
});
153+
154+
describe('refreshAfterChangeLanguage', () => {
155+
let originalUi;
156+
let originalGetCurrentUrl;
157+
158+
beforeEach(() => {
159+
// Pin the tested nameSpace value: environment is a shared mutable object and some specs
160+
// replace environment.ui entirely, so this test must not rely on the suite order.
161+
originalUi = (environment as any).ui;
162+
(environment as any).ui = Object.assign({}, originalUi, { nameSpace: '/angular-dspace' });
163+
originalGetCurrentUrl = (routeService as any).getCurrentUrl;
164+
});
165+
166+
afterEach(() => {
167+
(environment as any).ui = originalUi;
168+
// routeServiceStub is a shared module-level singleton - restore it
169+
(routeService as any).getCurrentUrl = originalGetCurrentUrl;
170+
});
171+
172+
it('should hard redirect to the absolute (nameSpace-aware) reload URL', fakeAsync(() => {
173+
// A relative 'reload/...' URL would be resolved against the current URL
174+
// (e.g. /items/<uuid>) and produce an invalid nested URL like /items/<uuid>/reload/...
175+
const currentUrl = '/items/1234';
176+
const fakeLocation = { href: '' };
177+
serviceAsAny._window = { nativeWindow: { location: fakeLocation } };
178+
(routeService as any).getCurrentUrl = jasmine.createSpy('getCurrentUrl').and.returnValue(of(currentUrl));
179+
180+
service.refreshAfterChangeLanguage();
181+
tick();
182+
183+
expect(fakeLocation.href).toMatch(new RegExp('^/angular-dspace/reload/[0-9]+\\?redirect=' + encodeURIComponent(currentUrl) + '$'));
184+
}));
185+
});
150186
});

src/app/core/locale/locale.service.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -191,8 +191,13 @@ export class LocaleService {
191191
public refreshAfterChangeLanguage() {
192192
this.routeService.getCurrentUrl().pipe(take(1)).subscribe((currentURL) => {
193193
// Hard redirect to the reload page with a unique number behind it
194-
// so that all state is definitely lost
195-
this._window.nativeWindow.location.href = `reload/${new Date().getTime()}?redirect=` + encodeURIComponent(currentURL);
194+
// so that all state is definitely lost.
195+
// The reload URL must be absolute (nameSpace-aware). A relative 'reload/...' URL is resolved
196+
// against the current URL and produces invalid nested URLs like /items/<uuid>/reload/...
197+
// which never match the 'reload/:rnd' route.
198+
const nameSpace = (environment.ui.nameSpace || '').replace(/\/$/, '');
199+
this._window.nativeWindow.location.href =
200+
`${nameSpace}/reload/${new Date().getTime()}?redirect=` + encodeURIComponent(currentURL);
196201
});
197202

198203
}

src/app/core/services/server-hard-redirect.service.spec.ts

Lines changed: 31 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ describe('ServerHardRedirectService', () => {
2323
});
2424

2525
describe('when performing a default redirect', () => {
26-
const redirect = 'test redirect';
26+
const redirect = '/test/redirect';
2727

2828
beforeEach(() => {
2929
service.redirect(redirect);
@@ -36,7 +36,7 @@ describe('ServerHardRedirectService', () => {
3636
});
3737

3838
describe('when performing a 301 redirect', () => {
39-
const redirect = 'test 301 redirect';
39+
const redirect = '/test/301/redirect';
4040
const redirectStatusCode = 301;
4141

4242
beforeEach(() => {
@@ -49,6 +49,35 @@ describe('ServerHardRedirectService', () => {
4949
});
5050
});
5151

52+
describe('when performing a redirect to a relative url', () => {
53+
// A relative URL in the Location header is resolved by the browser against the request URL,
54+
// e.g. 'reload/123' requested at /bitstreams/<uuid>/download resolves
55+
// to /bitstreams/<uuid>/reload/123 - such a redirect must never be emitted
56+
const redirect = 'reload/123456789';
57+
58+
beforeEach(() => {
59+
service.redirect(redirect);
60+
});
61+
62+
it('should redirect to the URL prefixed with a slash', () => {
63+
expect(mockResponse.redirect).toHaveBeenCalledWith(302, '/' + redirect);
64+
expect(mockResponse.end).toHaveBeenCalled();
65+
});
66+
});
67+
68+
describe('when performing a redirect to an external url', () => {
69+
const redirect = 'https://external-host.com/path';
70+
71+
beforeEach(() => {
72+
service.redirect(redirect);
73+
});
74+
75+
it('should redirect to the unchanged external URL', () => {
76+
expect(mockResponse.redirect).toHaveBeenCalledWith(302, redirect);
77+
expect(mockResponse.end).toHaveBeenCalled();
78+
});
79+
});
80+
5281
describe('when requesting the current route', () => {
5382

5483
beforeEach(() => {

src/app/core/services/server-hard-redirect.service.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,13 @@ export class ServerHardRedirectService extends HardRedirectService {
2626
*/
2727
redirect(url: string, statusCode?: number) {
2828

29+
// A relative URL in the Location header is resolved by the browser against the request URL
30+
// (e.g. 'reload/123' requested at /bitstreams/<uuid>/download resolves to
31+
// /bitstreams/<uuid>/reload/123) - make sure only absolute URLs are emitted
32+
if (!url.startsWith('/') && !/^https?:\/\//i.test(url)) {
33+
url = '/' + url;
34+
}
35+
2936
if (url === this.req.url) {
3037
return;
3138
}

src/app/static-page/static-page.component.spec.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,9 @@ describe('StaticPageComponent', () => {
2727
getCurrentLanguageCode: jasmine.createSpy('getCurrentLanguageCode'),
2828
});
2929

30-
appConfig = Object.assign(environment, {
30+
// Do not mutate the shared `environment` object - replacing `environment.ui` would
31+
// break any later spec that reads e.g. environment.ui.nameSpace
32+
appConfig = Object.assign({}, environment, {
3133
ui: {
3234
namespace: 'testNamespace'
3335
}

0 commit comments

Comments
 (0)