Clarin9/Redirect to login when an identifier resolves to a restricted object (#876) - #1429
Conversation
… object (#876) `lookupGuard` collapsed every failed lookup into a single boolean, so a 401/403 from `/server/api/pid/find` was indistinguishable from a genuine 404: an anonymous user opening a restricted item by handle got "No item found for the identifier ..." and had no way to authenticate. Branch on `RemoteData.statusCode` and delegate the 401/403 case to the shared `returnForbiddenUrlTreeOrLoginOnFalse` operator, which returns a UrlTree to the login page for anonymous users (remembering `state.url` so they come back to the identifier after logging in) and to the forbidden page for authenticated ones. Any other failure - 404 included - still activates the route so ObjectNotFoundComponent keeps rendering. This makes `/handle/...` and `/id/...` behave like `/items/:id`, which already gets this through `itemPageResolver` -> `redirectOn4xx`. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
This PR fixes identifier-based navigation for restricted objects by distinguishing “not found” from “access restricted” in lookupGuard, ensuring anonymous users are redirected to login (and authenticated users to /403) instead of seeing the ObjectNotFound page.
Changes:
- Update
lookupGuardto branch onRemoteData.statusCodeand delegate 401/403 handling toreturnForbiddenUrlTreeOrLoginOnFalse. - Extend unit tests for
lookupGuardto cover success, 404/500 failures, and 401/403 behavior for anonymous vs authenticated users.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 1 comment.
| File | Description |
|---|---|
| src/app/lookup-by-id/lookup-guard.ts | Implements 401/403 redirect behavior via returnForbiddenUrlTreeOrLoginOnFalse while keeping existing “failed lookup” behavior for other statuses. |
| src/app/lookup-by-id/lookup-guard.spec.ts | Adds behavioral specs validating the new guard outcomes for success, 404/500, and 401/403 (anonymous vs authenticated). |
- Set the 401/403 status on the server response before redirecting. Without it SSR would serve the login page with HTTP 200 under the identifier's own URL, and server.ts would store that in the bot cache for a day. No-op in the browser. - Add take(1): returnForbiddenUrlTreeOrLoginOnFalse combines with isAuthenticated(), a store selector that never completes, so the guard's observable relied on the router's own first() to terminate. - Reword the fallback comment - it is the catch-all for 404, 501, 5xx and status-less failures, not 404 only (Copilot) - and drop the claim that the authenticated branch behaves identically to /items/:id (a UrlTree rewrites the address bar, redirectOn4xx uses skipLocationChange). - Spec: use a non-completing isAuthenticated stub, assert the guard emits exactly once and completes, cover a failure with no status code and 401-while- authenticated, assert the server response status, and add a TestBed.runInInjectionContext case so the injected defaults are exercised. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
It asserted inside a subscribe callback with no done(), so it would have passed silently had the observable never emitted. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.
Suppressed comments (3)
src/app/lookup-by-id/lookup-guard.ts:62
- The comment mentions a 501 “identifier not resolvable” status, but elsewhere in the codebase 4xx handling explicitly accounts for 422 (see redirectOn4xx in core/shared/authorized.operators.ts). To avoid misleading future maintenance, reword this comment to mention 422 (or generalize to “non-401/403 failures”) rather than calling out 501 specifically.
// Any other failure - 404, 501 (identifier not resolvable), 5xx, or no response at all -
// activates the route so ObjectNotFoundComponent renders, exactly as before. On success
// DsoRedirectService has already triggered a hard redirect to the object's own page, so the
// route must not be activated.
return of(response.hasFailed);
src/app/lookup-by-id/lookup-guard.spec.ts:216
- This authenticated-401 branch now relies on ServerResponseService.setStatus(401) to keep SSR responses honest, but the spec doesn’t assert that behavior. Adding the assertion here makes the SSR/caching requirement harder to regress.
it('should return a UrlTree to the forbidden page', (done) => {
guard(handleRoute, state).subscribe((result) => {
expect(authService.setRedirectUrl).not.toHaveBeenCalled();
expect(router.parseUrl).toHaveBeenCalledWith('/403');
expect(result).toBe(forbiddenUrlTree);
done();
});
});
src/app/lookup-by-id/lookup-guard.spec.ts:232
- The guard sets the SSR status code for both 401 and 403 restricted lookups; this 403+anonymous case isn’t currently asserted. Adding an expectation here will better cover the new SSR-status behavior.
it('should store the requested url and return a UrlTree to the login page', (done) => {
guard(handleRoute, state).subscribe((result) => {
expect(authService.setRedirectUrl).toHaveBeenCalledWith(state.url);
expect(router.parseUrl).toHaveBeenCalledWith('login');
expect(result).toBe(loginUrlTree);
done();
});
});
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
401+authenticated and 403+anonymous went through the same setStatus call but were not covered. Verified: all four assertions fail without it. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Follow-up on the three suppressed comments from the last Copilot pass. Applied (2/3). The Not applied (1/3): the 501 -> 422 rewording. 422 does not occur on this endpoint.
(The 403 branch is kept as defence in depth — the endpoint itself never returns it, but the security layer can.) |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 2 out of 2 changed files in this pull request and generated no new comments.
Suppressed comments (2)
src/app/lookup-by-id/lookup-guard.spec.ts:138
- The guard’s fallback branch treats any non-401/403 failure the same, but the specs only cover 404/500/undefined. Since
redirectOn4xxand the PR description mention 422 as a possible outcome for unresolvable identifiers, it would be good to add a 422 spec to lock in the intended behavior (still show ObjectNotFound, no redirects).
describe('when the lookup fails with a 404', () => {
beforeEach(() => {
dsoService.findByIdAndIDType.and.returnValue(of(createFailedRemoteDataObject('Not found', 404)));
src/app/lookup-by-id/lookup-guard.ts:53
- The comment mentions a 501 status, but elsewhere in the codebase (e.g.
redirectOn4xxincore/shared/authorized.operators.ts) treats identifier resolution failures as 422. This comment is likely misleading for future maintenance; consider updating it to reflect the actual status codes you expect here (e.g. 404/422/5xx/network).
// Any other failure (404, 501, 5xx) activates the route so ObjectNotFoundComponent renders
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
|
Both remaining suppressed comments addressed in a052bc4. The 422 came from my own PR description, which listed On the spec: the fallback describe is now parameterised over 501, 422 and 500, so the "any non-401/403 status activates the route, no redirect, no SSR status" contract is locked in for all of them — including 422, even though this endpoint cannot produce it. |
Problem description
Fixes A1 of dataquest-dev/dspace-customers#876 (analysed in dataquest-dev/dspace-customers#871).
Anonymous →
http://dev-6.pc:8603/repository/handle/11234/1-f26b6363stays on the handle URL and renders"No item found for the identifier handle: 11234/1-f26b6363". No login redirect, no DiscoJuice — the user has no
way to authenticate and reach the item. The item exists and is only access-restricted (as admin the same handle
resolves;
GET /server/api/pid/find?id=hdl:…returns 401 for anonymous).It is inconsistent inside clarin9 itself: the same item opened by UUID does redirect anonymous users to
/repository/login. On 7.6.5 the handle path redirects too.Analysis
lookupGuardcollapsed every failure into one boolean:hasFailedistruefor 401, 403, 404 and 500 alike, andlookup-by-id-routes.tsrendersThemedObjectNotFoundComponentwhenever the guard returnstrue— so a restricted object is indistinguishable from amissing one. The HTTP status was available on the very same object (
RemoteData.statusCode, populated byremote-data-build.service.tsand passed through unchanged byDsoRedirectService); the guard just ignored it.This is not an SSR-vs-CSR difference and not the auth interceptor — both are equivalent on the two branches.
What 7.6.5 has and the v9 branch does not is a CLARIN patch inside
DsoRedirectService(commits992125b697"Login if restricted Item - accessing via
..handle/url (#612)" andbbba91247a"…not login page but 403 (#920)")that was never ported. Classic wiring-dropped: the guard, the routes, the component and the interceptor really are
identical — the behaviour lived in a fourth file.
Fix: branch on
RemoteData.statusCodein the guard and delegate the 401/403 case to the existing shared operatorreturnForbiddenUrlTreeOrLoginOnFalse(router, authService, state.url):false(hard redirect already fired)UrlTree→/login,setRedirectUrl(state.url)UrlTree→/403The identifier endpoint (
IdentifierRestRepository.getDSObyIdentifier) has exactly four outcomes: 302 + redirectwhen resolved and visible, 401 when resolved but
converter.toRestreturns null (restricted), 404 fornull/IdentifierNotFoundException, and 501SC_NOT_IMPLEMENTEDforIdentifierNotResolvableException. Itnever returns 422 - that status is handled by
redirectOn4xxbecause that operator is shared with resolvers overother endpoints. The 403 branch here is defence in depth: the endpoint itself never produces it, but the security
layer can.
Notes on the design:
redirectOn4xx(what/items/:iduses). It implements its redirect asfilter(… => false), i.e. it emitsnothing on a 4xx; Angular resolves
CanActivateFnwithfirst(), so an empty completion throwsEmptyErrorandproduces a
NavigationError. It also maps 404 →/404, which would delete the CLARIN-specificObjectNotFoundComponentthis route exists to render.returnForbiddenUrlTreeOrLoginOnFalseis the guard-shapedtwin of the same logic, already used by every authorization guard in v9, and returns a
UrlTree— the canonicalAngular 20 way for a guard to redirect.
DsoRedirectServicepatch: it useswindow.location.href(not SSR-safe), it makes adata service navigate as a side effect while the guard simultaneously returns
true(ObjectNotFound brieflyactivates and races the navigation), and it changes the constructor of an otherwise vanilla-identical file.
state.url(the requested identifier), notrouter.url— the latter is thepre-navigation URL and would send the user to
/after login.href, nowindow.location, noHardRedirectService: both theUrlTreeand the storedstate.urlarerouter-space values, so the
/repositorybase href is applied by the router./repository/id/<uuid>shares this guard and gets the same behaviour.Accepted trade-off: an anonymous probe can now distinguish "exists but restricted" from "does not exist". That is
the same surface
/items/:idand 7.6.5 already have, so this makes it consistent rather than larger.Tests
lookup-guard.spec.ts— the three existing argument tests keep passing (now passingstate/authService/routerpositionally) plus six new behavioural specs: success →
false; 404 →truewith no redirect; 500 →true; 401anonymous → login
UrlTree+setRedirectUrl; 403 anonymous → loginUrlTree; 403 authenticated → forbiddenUrlTreeand nosetRedirectUrl. 9/9 green locally, fullng lintclean.Problems
One thing that can only be confirmed on the live stack: does the backend return 404 (not 401) for a handle that does
not exist at all? If
/server/api/pid/findanswered 401 for unknown handles too, a typo'd handle would now bounceanonymous users to the login page instead of ObjectNotFound. If that turns out to be the case the fix belongs in the
backend (return 404 for unknown handles) — do not paper over it by treating 401 as 404, that would restore the reported
bug.
Manual Testing (if applicable)
/repository/handle/11234/1-f26b6363→ lands on/repository/loginwith DiscoJuice; after loggingin with an authorised account you end up on the item.
/403./repository/id/<uuid>.Copilot review