From a2c30ae285b29092f33252dfca456779610786d1 Mon Sep 17 00:00:00 2001 From: Anton Wiklund Date: Sun, 8 Nov 2026 21:58:02 +0100 Subject: [PATCH] fix(solid): keep the stored session when a restore attempt is old A load that found an unanswered silent restore attempt older than the restore deadline ended the stored session, on the reasoning that a working provider would have answered by then. A restore that ran out of time, or failed, ended it too. But another page can navigate the tab, keep it from leaving for the provider, and choose when it brings it back, so neither an attempt's age nor the deadline proves anything about the provider, and the learner could be logged out in every tab without any answer from it. Now only the provider's own refusal of the tab's request, which the sign-in client handles, ends the stored session on its own. An unanswered attempt of any age skips the silent restore and shows that the provider did not answer. A silent restore that runs out of time shows that it took too long and keeps the attempt, so the next load does not wait on the provider again; a login's answer that runs out of time keeps none. Both keep the stored session, for a later load to try again. So that a learner whose provider never answers is not stuck with it, the landing page that shows either error offers "Forget this session" while a session is stored. It ends the stored session without asking the provider and tells the other tabs, as a logout does. It takes a click on the app's own page. Refs: GHSA-73gp-72j2-85h8 --- docs/authentication.md | 51 ++++--- docs/solid-memo-client.md | 5 +- packages/application/src/ports.ts | 8 ++ packages/application/src/session.test.ts | 19 +++ packages/application/src/session.ts | 16 +++ packages/application/src/testing/fakePorts.ts | 2 + .../solid-memo-client/src/session.test.ts | 2 +- packages/solid-memo-client/src/session.ts | 2 +- packages/solid/src/client/interface.ts | 7 +- .../solid/src/client/session.library.test.ts | 14 +- .../solid/src/solidSessionGateway.test.ts | 135 +++++++++++++----- packages/solid/src/solidSessionGateway.ts | 66 +++++---- packages/ui/src/i18n/de.json | 1 + packages/ui/src/i18n/en.json | 1 + packages/ui/src/i18n/es.json | 1 + packages/ui/src/i18n/fr.json | 1 + packages/ui/src/i18n/ko.json | 1 + packages/ui/src/i18n/sv.json | 1 + packages/ui/src/test/fakes/session.ts | 2 + packages/ui/src/ui/App.test.tsx | 87 +++++++++++ packages/ui/src/ui/App.tsx | 34 +++++ 21 files changed, 357 insertions(+), 99 deletions(-) diff --git a/docs/authentication.md b/docs/authentication.md index be654a65..992ecc01 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -303,15 +303,15 @@ So the gateway bounds what stays on the page and notes what leaves it: and the login. It's a whole restore's allowance, not a request's: a restore can make a few requests, a slow but working provider should still restore, and `solid-memo-client` alone gives each request 30 - seconds. A restore that ends the stored session gives that step a - deadline of its own, so if ending it hangs too, the loading screen - can last up to twice as long. + seconds. The deadline keeps the stored session: another page can + keep the tab from leaving for the provider, so running out of time + doesn't show that the provider failed. - **After the deadline**: what the sign-in client is still doing can't be called off. With the Inrupt library, metadata that arrives late can still send the page to the provider, and the provider chooses when. The answer it then sends is refused or dropped. `solid-memo-client` doesn't store a session it completes after the - gateway gave up. + learner forgot it. - **The attempt**: before a silent restore, the gateway keeps the provider and the time in `sessionStorage` (`solid-memo:restore-attempt`) until the next load. A load that finds @@ -319,24 +319,35 @@ So the gateway bounds what stays on the page and notes what leaves it: provider hasn't sent the browser back. `restore()` rejects with `restoreNotReturned`, and the landing page adds a link to the provider beside the login. A load the learner started with Reload doesn't - count, since the page hadn't left. -- **The stored session**: a silent restore that fails or runs out of - time ends the stored session (`forgetSession()`), so the next visit - doesn't repeat it, and the learner logs in again. So does an attempt - older than the deadline that found no answer, as a working provider - has answered by then. The provider's own refusal of the request ends - it too, in the sign-in client. A younger attempt, and a refused - answer, keep the session, as they could also come from another page - navigating the tab: the next load tries again. A browser that says - it's offline keeps the session, as that says nothing of the provider. - A login's own answer leaves the stored session alone. + count, since the page hadn't left. A silent restore that runs out of + time keeps the attempt too, so the next load doesn't wait on the + provider again, and the load after that tries again. A login's + answer that runs out of time keeps none. +- **The stored session**: only the provider's own refusal of this tab's + request, an answer whose `state` matches it, ends the stored session, + in the sign-in client. Neither an attempt's age nor the deadline + does, since another page can control both: it can navigate the tab, + keep it from leaving, and choose when it brings it back. A restore + that fails, an attempt that found no answer, and a refused answer + keep the session too, and the next load tries again. A login's own + answer leaves the stored session alone. +- **Forget this session**: the landing page that shows + `restoreTimedOut` or `restoreNotReturned` offers "Forget this + session" while a session is stored. It ends the stored session in + the sign-in client (`forgetSession()`), without asking the provider, + and the restore attempt, and announces a logout to the other tabs. + The landing page then shows no error. It takes the learner's click on + the app's own page. That's the cost of the deadline keeping the + session: with a provider that never answers, each fresh load waits up + to the deadline, or shows that the provider didn't answer, until the + learner logs in again or forgets the session. - **A page brought back**: the browser can bring a page that left for the provider back from its back/forward cache, its restore still - pending and its deadline running again. A later load in the tab may - have restored the session and taken the attempt by then. Such a page - reloads, and if its deadline still runs out, it leaves the session - and what that load kept alone: only the page whose attempt the tab - still holds ends the stored session. + pending and its deadline running again. Another page can bring it + back too, so its deadline keeps the session, as any deadline does. A + later load in the tab may have restored the session and taken the + attempt by then. Such a page reloads, and if its deadline still runs + out, it leaves what that load kept alone. ### The issuer in the WebID's profile diff --git a/docs/solid-memo-client.md b/docs/solid-memo-client.md index 586d29a1..e5816e27 100644 --- a/docs/solid-memo-client.md +++ b/docs/solid-memo-client.md @@ -158,9 +158,10 @@ The session's requests are in gateway keeps before a silent restore leaves the page ([authentication.md](authentication.md#the-issuer-on-the-providers-answer)). - **`forgetSession()`** ends the stored session and any request under - way in this tab, as the gateway asks after a silent restore that - failed or was given up on + way in this tab, without asking the provider anything, as the learner + asks after a restore the provider left unanswered ([authentication.md](authentication.md#when-the-provider-doesnt-answer)). + A restore still under way then stores nothing. - **`requestIssuer(state)`** names the issuer of the request under way in this tab when `state` is its state, and none otherwise. - **A request** carries `Authorization: DPoP` with the access token and diff --git a/packages/application/src/ports.ts b/packages/application/src/ports.ts index 8c11d5f1..e373b482 100644 --- a/packages/application/src/ports.ts +++ b/packages/application/src/ports.ts @@ -78,6 +78,14 @@ export interface SessionGateway { */ loginWithIssuer(oidcIssuer: string): Promise; logout(): Promise; + /** Whether a session is stored in this browser for a restore to try. */ + hasStoredSession(): Promise; + /** + * Ends the stored session without asking its identity provider, which + * may never answer a restore, and what this tab kept for the restore: + * the next load restores nothing. + */ + forgetStoredSession(): Promise; } /** Driven port: read access to WebID documents. */ diff --git a/packages/application/src/session.test.ts b/packages/application/src/session.test.ts index 9261b2a4..aeb0ba35 100644 --- a/packages/application/src/session.test.ts +++ b/packages/application/src/session.test.ts @@ -155,6 +155,25 @@ describe("session", () => { await expect(useCases.logout()).rejects.toThrow("storage refused"); expect(deps.logoutChannel.announce).toHaveBeenCalledOnce(); }); + it("tells whether a session is stored, as the session gateway does", async () => { + const deps = makeDeps(); + vi.mocked(deps.sessionGateway.hasStoredSession).mockResolvedValueOnce(true); + await expect(createUseCases(deps).hasStoredSession()).resolves.toBe(true); + }); + it("forgets the stored session and tells the other tabs, even when forgetting it fails", async () => { + const deps = makeDeps(); + const useCases = createUseCases(deps); + await useCases.forgetStoredSession(); + expect(deps.sessionGateway.forgetStoredSession).toHaveBeenCalledOnce(); + expect(deps.sessionGateway.logout).not.toHaveBeenCalled(); + expect(deps.logoutChannel.announce).toHaveBeenCalledOnce(); + + vi.mocked(deps.sessionGateway.forgetStoredSession).mockRejectedValueOnce( + new Error("storage refused"), + ); + await expect(useCases.forgetStoredSession()).rejects.toThrow("storage refused"); + expect(deps.logoutChannel.announce).toHaveBeenCalledTimes(2); + }); describe("a logout in another tab", () => { /** The use cases over a channel the test can speak on, as another tab's logout would. */ function subscribed(deps: ReturnType) { diff --git a/packages/application/src/session.ts b/packages/application/src/session.ts index 0bb3b3e1..fb7969be 100644 --- a/packages/application/src/session.ts +++ b/packages/application/src/session.ts @@ -80,6 +80,14 @@ export interface SessionUseCases { loginWithProvider(oidcIssuer: string): Promise; /** Ends the session in this tab, and tells this browser's other tabs to end theirs. */ logout(): Promise; + /** Whether a session is stored in this browser for a restore to try. */ + hasStoredSession(): Promise; + /** + * Ends the stored session without asking its provider, for a learner + * whose provider never answers a restore, and tells this browser's + * other tabs to end theirs, as a logout does. + */ + forgetStoredSession(): Promise; /** Subscribe to session expiry; returns an unsubscribe function. */ onSessionExpired(listener: () => void): () => void; /** @@ -509,6 +517,14 @@ export function createSessionUseCases({ logoutChannel.announce(); return sessionGateway.logout(); }, + hasStoredSession() { + return sessionGateway.hasStoredSession(); + }, + forgetStoredSession() { + // First, as for a logout. + logoutChannel.announce(); + return sessionGateway.forgetStoredSession(); + }, onLoggedOutElsewhere(listener) { const endSession = () => { // Only a tab with a user has a session to end: this logout is local, and announces nothing. diff --git a/packages/application/src/testing/fakePorts.ts b/packages/application/src/testing/fakePorts.ts index 246468e0..dac74436 100644 --- a/packages/application/src/testing/fakePorts.ts +++ b/packages/application/src/testing/fakePorts.ts @@ -111,6 +111,8 @@ export function makeDeps() { login: vi.fn(async () => undefined), loginWithIssuer: vi.fn(async () => undefined), logout: vi.fn(async () => undefined), + hasStoredSession: vi.fn(async () => false), + forgetStoredSession: vi.fn(async () => undefined), onSessionExpired: vi.fn(() => () => undefined), onReturnedWithoutSession: vi.fn((_listener: () => void): (() => void) => vi.fn()), }; diff --git a/packages/solid-memo-client/src/session.test.ts b/packages/solid-memo-client/src/session.test.ts index a8bc9060..5bc15260 100644 --- a/packages/solid-memo-client/src/session.test.ts +++ b/packages/solid-memo-client/src/session.test.ts @@ -432,7 +432,7 @@ describe("createSessionClient, over oauth4webapi", () => { expect(client.webId()).toBeUndefined(); }); - it("does not store a session it completes after a restore was given up on", async () => { + it("does not store a session it completes after it was forgotten mid-restore", async () => { await createSessionClient().login(LOGIN); sendBack(); const client = createSessionClient(); diff --git a/packages/solid-memo-client/src/session.ts b/packages/solid-memo-client/src/session.ts index f6ae2136..4fa4cd1b 100644 --- a/packages/solid-memo-client/src/session.ts +++ b/packages/solid-memo-client/src/session.ts @@ -83,7 +83,7 @@ async function completeRedirect( await saveSession(stored); return true; }); - // Given up on while the provider answered: the next load restores nothing. + // Forgotten while the provider answered, a restore past its deadline: the next load restores nothing. if (!saved) return LOGGED_OUT; setCurrentSession(new OidcSession(as, stored, tokens)); return { webId, fromLogin: !silent, restoredFrom: pending.silentFrom ?? null }; diff --git a/packages/solid/src/client/interface.ts b/packages/solid/src/client/interface.ts index 8e08f6d7..7e1cc5dc 100644 --- a/packages/solid/src/client/interface.ts +++ b/packages/solid/src/client/interface.ts @@ -210,9 +210,10 @@ export interface SessionClient { */ sessionIssuer(): Promise; /** - * Ends the stored session after a restore that failed or was given - * up on, even one still under way, without asking the identity - * provider anything: the next load restores nothing. + * Ends the stored session, even one a restore is still under way + * with, without asking the identity provider anything, which may never + * answer: the next load restores nothing. The learner asks for it on + * the landing page after a restore the provider left unanswered. */ forgetSession(): Promise; /** diff --git a/packages/solid/src/client/session.library.test.ts b/packages/solid/src/client/session.library.test.ts index 07663ecc..ab4fe7c8 100644 --- a/packages/solid/src/client/session.library.test.ts +++ b/packages/solid/src/client/session.library.test.ts @@ -558,21 +558,23 @@ describe("createSessionClient, over a restore that never settles", () => { ); vi.spyOn(console, "error").mockImplementation(() => {}); vi.useFakeTimers({ toFake: ["setTimeout", "clearTimeout"] }); - try { - const gateway = createSolidSessionGateway({ + const gateway = () => + createSolidSessionGateway({ clientName: "Solid Memo", site: "https://memo.example/", client: createSessionClient(), }); - const given = expect(gateway.restore()).rejects.toMatchObject({ code: "restoreTimedOut" }); + try { + const given = expect(gateway().restore()).rejects.toMatchObject({ code: "restoreTimedOut" }); await vi.advanceTimersByTimeAsync(60_000); await given; } finally { vi.useRealTimers(); vi.restoreAllMocks(); } - // The next visit has nothing to restore silently, so it does not repeat this. - expect(window.localStorage.getItem("solidClientAuthn:currentSession")).toBeNull(); - await expect(createSessionClient().sessionIssuer()).resolves.toBeNull(); + // The session stays, as only the provider's refusal ends it, but the next visit does not wait on it again. + expect(window.localStorage.getItem("solidClientAuthn:currentSession")).toBe(sessionId); + await expect(gateway().restore()).rejects.toMatchObject({ code: "restoreNotReturned" }); + window.sessionStorage.clear(); }); }); diff --git a/packages/solid/src/solidSessionGateway.test.ts b/packages/solid/src/solidSessionGateway.test.ts index 0e538d4c..a994bf62 100644 --- a/packages/solid/src/solidSessionGateway.test.ts +++ b/packages/solid/src/solidSessionGateway.test.ts @@ -377,7 +377,7 @@ describe("createSolidSessionGateway", () => { window.dispatchEvent(Object.assign(new Event("pageshow"), { persisted })); } - it("gives up at the deadline, ends the stored session and keeps nothing for an answer", async () => { + it("gives up at the deadline, keeps the stored session, and keeps only the attempt for the next load", async () => { neverSettles(); const restoring = createSolidSessionGateway(options).restore(); const given = expect(restoring).rejects.toMatchObject({ @@ -385,13 +385,27 @@ describe("createSolidSessionGateway", () => { vars: { seconds: DEADLINE_MS / 1000 }, }); await vi.advanceTimersByTimeAsync(DEADLINE_MS - 1); - expect(client.forgetSession).not.toHaveBeenCalled(); expect(window.sessionStorage.getItem("solid-memo:restore-attempt")).toContain(ISSUER); await vi.advanceTimersByTimeAsync(1); await given; - expect(client.forgetSession).toHaveBeenCalledOnce(); + expect(client.forgetSession).not.toHaveBeenCalled(); + // Nothing is kept for an answer, which the next load then drops. expect(window.sessionStorage.getItem("solid-memo:login-issuer")).toBeNull(); + + // The next load does not wait on the provider again, and the one after tries again. + loadedBy("navigate"); + client.restore.mockClear(); + await expect(createSolidSessionGateway(options).restore()).rejects.toMatchObject({ + code: "restoreNotReturned", + vars: { issuer: ISSUER }, + }); + expect(client.restore).not.toHaveBeenCalled(); expect(window.sessionStorage.getItem("solid-memo:restore-attempt")).toBeNull(); + restored(false); + await expect(createSolidSessionGateway(options).restore()).resolves.toMatchObject({ + origin: "restored", + }); + expect(client.forgetSession).not.toHaveBeenCalled(); }); it("still restores with a provider that answers just inside the deadline", async () => { @@ -412,37 +426,12 @@ describe("createSolidSessionGateway", () => { expect(vi.getTimerCount()).toBe(0); }); - it("ends the stored session after a silent restore that fails, so the next visit does not repeat it", async () => { - client.sessionIssuer.mockResolvedValue(ISSUER); - client.restore.mockRejectedValue(new Error("boom")); - await expect(createSolidSessionGateway(options).restore()).rejects.toThrow("boom"); - expect(client.forgetSession).toHaveBeenCalledOnce(); - }); - - it("keeps the stored session when the browser is offline, which says nothing of the provider", async () => { - vi.spyOn(window.navigator, "onLine", "get").mockReturnValue(false); + it("keeps the stored session after a silent restore that fails, and forgets the attempt", async () => { client.sessionIssuer.mockResolvedValue(ISSUER); client.restore.mockRejectedValue(new Error("boom")); await expect(createSolidSessionGateway(options).restore()).rejects.toThrow("boom"); expect(client.forgetSession).not.toHaveBeenCalled(); - }); - - it("gets past a stored session that can't be ended, still with the restore's error", async () => { - neverSettles(); - client.forgetSession.mockReturnValue(new Promise(() => {})); - const given = expect(createSolidSessionGateway(options).restore()).rejects.toMatchObject({ - code: "restoreTimedOut", - }); - await vi.advanceTimersByTimeAsync(2 * DEADLINE_MS); - await given; - expect(vi.getTimerCount()).toBe(0); - - client.forgetSession.mockRejectedValue(new Error("blocked")); - const again = expect(createSolidSessionGateway(options).restore()).rejects.toMatchObject({ - code: "restoreTimedOut", - }); - await vi.advanceTimersByTimeAsync(DEADLINE_MS); - await again; + expect(window.sessionStorage.getItem("solid-memo:restore-attempt")).toBeNull(); }); it.each([ @@ -488,18 +477,27 @@ describe("createSolidSessionGateway", () => { expect(reload).toHaveBeenCalledOnce(); }); - it("waits for the deadline, then ends the stored session, when the page brought back still holds its attempt", async () => { + it("waits for the deadline, then keeps the stored session, when the page brought back still holds its attempt", async () => { const reload = vi.spyOn(window.location, "reload").mockImplementation(() => {}); const { restoring } = await restoreThatLeft(); + // Another page may have sent the tab away mid-restore and brought this page back. pageShown(true); await vi.advanceTimersByTimeAsync(DEADLINE_MS); await expect(restoring).rejects.toMatchObject({ code: "restoreTimedOut" }); expect(reload).not.toHaveBeenCalled(); - expect(client.forgetSession).toHaveBeenCalledOnce(); + expect(client.forgetSession).not.toHaveBeenCalled(); + }); + + it("keeps the stored session at the deadline of a page another page kept from leaving", async () => { + // The page's own navigation to the provider is called off each time, so it never leaves. + const { restoring } = await restoreThatLeft(); + await vi.advanceTimersByTimeAsync(DEADLINE_MS); + await expect(restoring).rejects.toMatchObject({ code: "restoreTimedOut" }); + expect(client.forgetSession).not.toHaveBeenCalled(); }); it.each(["back_forward", "navigate"])( - "skips the silent restore, and ends the stored session, when an attempt found by a %s load is past what a working provider takes", + "skips the silent restore but keeps the stored session when an attempt found by a %s load is past what a working provider takes", async (type) => { attemptMade(DEADLINE_MS); loadedBy(type); @@ -509,7 +507,7 @@ describe("createSolidSessionGateway", () => { }); expect(client.restore).not.toHaveBeenCalled(); expect(client.sessionIssuer).not.toHaveBeenCalled(); - expect(client.forgetSession).toHaveBeenCalledOnce(); + expect(client.forgetSession).not.toHaveBeenCalled(); expect(window.sessionStorage.getItem("solid-memo:restore-attempt")).toBeNull(); }, ); @@ -532,6 +530,26 @@ describe("createSolidSessionGateway", () => { }, ); + it("keeps the stored session when another page interrupts a restore and brings the tab back much later", async () => { + await restoreThatLeft(); + + // Another page sent the tab away mid-restore and brings it back, with no answer, when it likes. + attemptMade(10 * DEADLINE_MS); + loadedBy("navigate"); + client.restore.mockClear(); + await expect(createSolidSessionGateway(options).restore()).rejects.toMatchObject({ + code: "restoreNotReturned", + }); + expect(client.forgetSession).not.toHaveBeenCalled(); + + // The session is still there for the next load to restore. + restored(false); + await expect(createSolidSessionGateway(options).restore()).resolves.toMatchObject({ + origin: "restored", + }); + expect(client.forgetSession).not.toHaveBeenCalled(); + }); + it("restores anew when the learner reloaded the page, which had not left", async () => { await restoreThatLeft(); @@ -580,13 +598,13 @@ describe("createSolidSessionGateway", () => { }, ); - it("ends the stored session when a silent restore's accepted answer fails", async () => { + it("keeps the stored session when a silent restore's accepted answer fails", async () => { attemptMade(0); client.requestIssuer.mockResolvedValue(ISSUER); client.restore.mockRejectedValue(new Error("boom")); window.history.replaceState(null, "", `/?code=c&state=s&iss=${encodeURIComponent(ISSUER)}`); await expect(createSolidSessionGateway(options).restore()).rejects.toThrow("boom"); - expect(client.forgetSession).toHaveBeenCalledOnce(); + expect(client.forgetSession).not.toHaveBeenCalled(); expect(window.location.search).toBe(""); }); @@ -603,6 +621,26 @@ describe("createSolidSessionGateway", () => { expect(client.forgetSession).not.toHaveBeenCalled(); expect(window.location.search).toBe(""); }); + + it("keeps no attempt past a login's answer that ran out of time with an older session stored", async () => { + window.sessionStorage.setItem("solid-memo:login-issuer", ISSUER); + client.requestIssuer.mockResolvedValue(ISSUER); + neverSettles(); + window.history.replaceState(null, "", `/?code=c&state=s&iss=${encodeURIComponent(ISSUER)}`); + const given = expect(createSolidSessionGateway(options).restore()).rejects.toMatchObject({ + code: "restoreTimedOut", + }); + await vi.advanceTimersByTimeAsync(DEADLINE_MS); + await given; + expect(window.sessionStorage.getItem("solid-memo:restore-attempt")).toBeNull(); + + // So the next load restores the session, not saying that the provider did not send the learner back. + loadedBy("navigate"); + restored(false); + await expect(createSolidSessionGateway(options).restore()).resolves.toMatchObject({ + origin: "restored", + }); + }); }); describe("login", () => { @@ -732,6 +770,31 @@ describe("createSolidSessionGateway", () => { }); }); + describe("hasStoredSession", () => { + it("tells whether the client has a session stored", async () => { + const gateway = createSolidSessionGateway(options); + client.sessionIssuer.mockResolvedValue("https://issuer.example/"); + await expect(gateway.hasStoredSession()).resolves.toBe(true); + client.sessionIssuer.mockResolvedValue(null); + await expect(gateway.hasStoredSession()).resolves.toBe(false); + }); + }); + + describe("forgetStoredSession", () => { + it("ends the stored session, and forgets the restore attempt, so the next load restores nothing", async () => { + window.sessionStorage.setItem( + "solid-memo:restore-attempt", + JSON.stringify({ issuer: "https://issuer.example/", at: Date.now() }), + ); + window.sessionStorage.setItem("solid-memo:login-issuer", "https://issuer.example/"); + await createSolidSessionGateway(options).forgetStoredSession(); + expect(client.forgetSession).toHaveBeenCalledOnce(); + expect(client.logout).not.toHaveBeenCalled(); + expect(window.sessionStorage.getItem("solid-memo:restore-attempt")).toBeNull(); + expect(window.sessionStorage.getItem("solid-memo:login-issuer")).toBeNull(); + }); + }); + describe("logout", () => { it("delegates to the client", async () => { const gateway = createSolidSessionGateway(options); diff --git a/packages/solid/src/solidSessionGateway.ts b/packages/solid/src/solidSessionGateway.ts index 39522aba..b071b63a 100644 --- a/packages/solid/src/solidSessionGateway.ts +++ b/packages/solid/src/solidSessionGateway.ts @@ -108,7 +108,11 @@ export const LOGIN_ISSUER_KEY = "solid-memo:login-issuer"; */ const RESTORE_ATTEMPT_KEY = "solid-memo:restore-attempt"; -/** A silent restore this tab set off: the provider, and when, in milliseconds since the epoch. */ +/** + * A silent restore this tab set off: the provider, and when, in + * milliseconds since the epoch, by which a page tells its own attempt + * from a later load's. + */ interface RestoreAttempt { issuer: string; at: number; @@ -118,9 +122,7 @@ interface RestoreAttempt { * How long a restore may keep the page on its loading screen, in * milliseconds (docs/authentication.md#session-restore): twice what * solid-memo-client gives one request to the provider, as a restore - * makes a few, and a false alarm costs the learner their session. An - * attempt that left the page gets as long to come back: only one older - * than this says the provider isn't answering. + * makes a few, and a false alarm stops a restore that would have worked. */ const RESTORE_DEADLINE_MS = 60_000; @@ -324,36 +326,32 @@ export function createSolidSessionGateway({ } /** - * Ends the stored session, unless the browser is offline, which says - * nothing of the provider. It stays bounded like the restore, and the - * learner sees the restore's error whatever becomes of it. - */ - async function endStoredSession() { - if (!window.navigator.onLine) return; - await withinRestoreDeadline(client.forgetSession()).catch(() => {}); - } - - /** - * Checks the provider's answer and restores, within the deadline. A - * silent restore that fails or runs out of time ends the stored - * session, so that the next visit does not repeat it, and so does an - * attempt that went unanswered for longer than a working provider - * takes. Another page can navigate this tab, so neither a young - * attempt nor a refused answer says anything of the provider: they - * skip the restore and show the error, and the session stays. So does - * it past a login's answer. What was kept for a provider's answer stays - * only if the page leaves, as a restore that left never comes back here. + * Checks the provider's answer and restores, within the deadline. Only + * the provider's own refusal of this tab's request ends the stored + * session, in the sign-in client. Another page can navigate this tab, + * keep it from leaving and choose when it brings it back, so neither + * an unanswered attempt, however old, nor a refused answer, nor the + * deadline says anything of the provider: they show the error, and the + * session stays, until the learner logs in again or forgets it + * (forgetStoredSession). Past the deadline of a silent restore its + * attempt stays kept, so that the next load does not wait on the + * provider again but shows that it did not answer; the load after that + * tries again. A login's answer keeps none. What was kept for a + * provider's answer stays only if the page leaves, as a restore that + * left never comes back here. * * The browser can bring a page that left back from its back/forward * cache, its restore still pending and its timer running again. If a * later load has taken this page's attempt from the tab by then, the - * restore is no longer this page's to end: it reloads, and whatever - * still settles here leaves the session and what that load kept alone. + * restore is no longer this page's: it reloads, and whatever still + * settles here leaves what that load kept alone. */ async function attemptRestore(loginIssuer: string | null, attempt: RestoreAttempt | null) { const answered = hasAuthorizationResponse(); - let endsSession = false; let ownAttempt: string | null = null; + // A login's answer is no silent restore, whatever session is stored from before it. + const silent = !answered || loginIssuer === null || attempt !== null; + let keepsAttempt = false; const takenOver = () => ownAttempt !== null && window.sessionStorage.getItem(RESTORE_ATTEMPT_KEY) !== ownAttempt; const reloadIfTakenOver = (event: PageTransitionEvent) => { @@ -362,18 +360,16 @@ export function createSolidSessionGateway({ window.addEventListener("pageshow", reloadIfTakenOver); try { if (attempt !== null && !answered && !isReload()) { - endsSession = Date.now() - attempt.at >= RESTORE_DEADLINE_MS; throw new AppError("restoreNotReturned", { issuer: attempt.issuer }); } return await withinRestoreDeadline( checkAuthorizationResponse(loginIssuer, client).then(async () => { - endsSession = !answered || loginIssuer === null || attempt !== null; ownAttempt = await noteRestoreAttempt(); return client.restore(); }), ); } catch (error) { - if (endsSession && !takenOver()) await endStoredSession(); + keepsAttempt = silent && error instanceof AppError && error.code === "restoreTimedOut"; if (hasAuthorizationResponse()) { takeOffAuthorizationResponse(new URL(window.location.href)); } @@ -382,7 +378,7 @@ export function createSolidSessionGateway({ window.removeEventListener("pageshow", reloadIfTakenOver); if (!takenOver()) { window.sessionStorage.removeItem(LOGIN_ISSUER_KEY); - window.sessionStorage.removeItem(RESTORE_ATTEMPT_KEY); + if (!keepsAttempt) window.sessionStorage.removeItem(RESTORE_ATTEMPT_KEY); } } } @@ -422,6 +418,16 @@ export function createSolidSessionGateway({ await client.logout(); }, + async hasStoredSession() { + return (await client.sessionIssuer()) !== null; + }, + + async forgetStoredSession() { + window.sessionStorage.removeItem(LOGIN_ISSUER_KEY); + window.sessionStorage.removeItem(RESTORE_ATTEMPT_KEY); + await client.forgetSession(); + }, + onSessionExpired(listener) { window.addEventListener(SESSION_EXPIRED_EVENT, listener); return () => window.removeEventListener(SESSION_EXPIRED_EVENT, listener); diff --git a/packages/ui/src/i18n/de.json b/packages/ui/src/i18n/de.json index afeaed6b..f249d4d9 100644 --- a/packages/ui/src/i18n/de.json +++ b/packages/ui/src/i18n/de.json @@ -140,6 +140,7 @@ "app": { "restoringSession": "Sitzung wird wiederhergestellt…", "backToProvider": "Zurück zu deinem Anbieter", + "forgetSession": "Diese Sitzung vergessen", "skipToContent": "Zum Inhalt springen", "tagline": "Karteikarten mit verteilter Wiederholung, die in deinem eigenen Solid Pod leben.", "sessionExpired": "Deine Sitzung ist abgelaufen. Bitte melde dich erneut an.", diff --git a/packages/ui/src/i18n/en.json b/packages/ui/src/i18n/en.json index c1d05d15..c9307985 100644 --- a/packages/ui/src/i18n/en.json +++ b/packages/ui/src/i18n/en.json @@ -140,6 +140,7 @@ "app": { "restoringSession": "Restoring session…", "backToProvider": "Go back to your provider", + "forgetSession": "Forget this session", "skipToContent": "Skip to content", "tagline": "Spaced-repetition flashcards that live in your own Solid Pod.", "sessionExpired": "Your session has expired. Please log in again.", diff --git a/packages/ui/src/i18n/es.json b/packages/ui/src/i18n/es.json index 7f9205db..2a1d235f 100644 --- a/packages/ui/src/i18n/es.json +++ b/packages/ui/src/i18n/es.json @@ -142,6 +142,7 @@ "app": { "restoringSession": "Restaurando la sesión…", "backToProvider": "Volver a tu proveedor", + "forgetSession": "Olvidar esta sesión", "skipToContent": "Saltar al contenido", "tagline": "Tarjetas de memoria con repetición espaciada que viven en tu propio Pod de Solid.", "sessionExpired": "Tu sesión ha caducado. Vuelve a iniciar sesión.", diff --git a/packages/ui/src/i18n/fr.json b/packages/ui/src/i18n/fr.json index b2897dfd..b93107f4 100644 --- a/packages/ui/src/i18n/fr.json +++ b/packages/ui/src/i18n/fr.json @@ -142,6 +142,7 @@ "app": { "restoringSession": "Restauration de la session…", "backToProvider": "Retourner chez votre fournisseur", + "forgetSession": "Oublier cette session", "skipToContent": "Aller au contenu", "tagline": "Des cartes mémoire à répétition espacée, conservées dans votre propre Pod Solid.", "sessionExpired": "Votre session a expiré. Veuillez vous reconnecter.", diff --git a/packages/ui/src/i18n/ko.json b/packages/ui/src/i18n/ko.json index c7e54449..2c80601c 100644 --- a/packages/ui/src/i18n/ko.json +++ b/packages/ui/src/i18n/ko.json @@ -138,6 +138,7 @@ "app": { "restoringSession": "세션을 복원하는 중…", "backToProvider": "제공자로 돌아가기", + "forgetSession": "이 세션 지우기", "skipToContent": "본문으로 건너뛰기", "tagline": "내 Solid Pod에 저장되는 간격 반복 플래시카드.", "sessionExpired": "세션이 만료됐어요. 다시 로그인해 주세요.", diff --git a/packages/ui/src/i18n/sv.json b/packages/ui/src/i18n/sv.json index ba208b1f..745102cf 100644 --- a/packages/ui/src/i18n/sv.json +++ b/packages/ui/src/i18n/sv.json @@ -140,6 +140,7 @@ "app": { "restoringSession": "Återställer sessionen…", "backToProvider": "Gå tillbaka till din leverantör", + "forgetSession": "Glöm den här sessionen", "skipToContent": "Hoppa till innehållet", "tagline": "Flashcards med intervallrepetition som bor i din egen Solid Pod.", "sessionExpired": "Din session har gått ut. Logga in igen.", diff --git a/packages/ui/src/test/fakes/session.ts b/packages/ui/src/test/fakes/session.ts index 9c43ec6d..fbdf793d 100644 --- a/packages/ui/src/test/fakes/session.ts +++ b/packages/ui/src/test/fakes/session.ts @@ -41,6 +41,8 @@ export function makeSessionUseCasesFake( loginWithWebId: vi.fn(async () => undefined), loginWithProvider: vi.fn(async () => undefined), logout: vi.fn(async () => undefined), + hasStoredSession: vi.fn(async () => false), + forgetStoredSession: vi.fn(async () => undefined), onSessionExpired: vi.fn(() => () => undefined), onLoggedOutElsewhere: vi.fn(() => () => undefined), discoverAccount: vi.fn(async (session) => ({ diff --git a/packages/ui/src/ui/App.test.tsx b/packages/ui/src/ui/App.test.tsx index 828a63b9..33dec73b 100644 --- a/packages/ui/src/ui/App.test.tsx +++ b/packages/ui/src/ui/App.test.tsx @@ -891,6 +891,93 @@ describe("AppShell", () => { expect(screen.getByLabelText("WebID")).toBeInTheDocument(); }); + it.each([ + new AppError("restoreTimedOut", { seconds: 60 }), + new AppError("restoreNotReturned", { issuer: "https://issuer.example/" }), + ])("offers to forget a stored session a provider left unanswered ($code)", async (error) => { + const forgetStoredSession = vi.fn(async () => undefined); + renderApp( + makeUseCases({ + restoreSession: vi.fn(async () => { + throw error; + }), + hasStoredSession: vi.fn(async () => true), + forgetStoredSession, + }), + ); + fireEvent.click(await screen.findByRole("button", { name: "Forget this session" })); + expect(forgetStoredSession).toHaveBeenCalledOnce(); + // The plain landing page then: no error, and nothing more to forget. + await waitFor(() => + expect(screen.queryByRole("button", { name: "Forget this session" })).toBeNull(), + ); + expect(screen.queryByText(/Solid Memo stopped restoring your session/)).toBeNull(); + expect(screen.queryByRole("link", { name: /Go back to your provider/ })).toBeNull(); + expect(screen.getByLabelText("WebID")).toBeInTheDocument(); + }); + + it("offers nothing to forget when no session is stored, or the restore failed otherwise", async () => { + const hasStoredSession = vi.fn(async () => false); + const { unmount } = renderApp( + makeUseCases({ + restoreSession: vi.fn(async () => { + throw new AppError("restoreTimedOut", { seconds: 60 }); + }), + hasStoredSession, + }), + ); + expect(await screen.findByText(/did not answer in time/)).toBeInTheDocument(); + expect(hasStoredSession).toHaveBeenCalledOnce(); + expect(screen.queryByRole("button", { name: "Forget this session" })).toBeNull(); + unmount(); + + // A store that can't be read offers nothing either. + const unreadable = vi.fn(async () => { + throw new Error("storage refused"); + }); + const second = renderApp( + makeUseCases({ + restoreSession: vi.fn(async () => { + throw new AppError("restoreNotReturned", { issuer: "https://issuer.example/" }); + }), + hasStoredSession: unreadable, + }), + ); + expect(await screen.findByText(/did not send you back/)).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Forget this session" })).toBeNull(); + second.unmount(); + + const notAsked = vi.fn(async () => true); + renderApp( + makeUseCases({ + restoreSession: vi.fn(async () => { + throw new Error("restore failed"); + }), + hasStoredSession: notAsked, + }), + ); + expect(await screen.findByText("restore failed")).toBeInTheDocument(); + expect(notAsked).not.toHaveBeenCalled(); + expect(screen.queryByRole("button", { name: "Forget this session" })).toBeNull(); + }); + + it("shows why a session could not be forgotten, and stops offering it", async () => { + renderApp( + makeUseCases({ + restoreSession: vi.fn(async () => { + throw new AppError("restoreTimedOut", { seconds: 60 }); + }), + hasStoredSession: vi.fn(async () => true), + forgetStoredSession: vi.fn(async () => { + throw new Error("storage refused"); + }), + }), + ); + fireEvent.click(await screen.findByRole("button", { name: "Forget this session" })); + expect(await screen.findByText("storage refused")).toBeInTheDocument(); + expect(screen.queryByRole("button", { name: "Forget this session" })).toBeNull(); + }); + it("logs in with the entered WebID and stays busy while redirecting", async () => { const loginWithWebId = vi.fn(() => new Promise(() => {})); const useCases = makeUseCases({ loginWithWebId }); diff --git a/packages/ui/src/ui/App.tsx b/packages/ui/src/ui/App.tsx index 5406a1e4..4b9cd558 100644 --- a/packages/ui/src/ui/App.tsx +++ b/packages/ui/src/ui/App.tsx @@ -304,6 +304,14 @@ function providerNotReturning(error: unknown): string | null { : null; } +/** Whether a restore was stopped with no answer from the provider, which may never give one. */ +function restoreUnanswered(error: unknown): boolean { + return ( + error instanceof AppError && + (error.code === "restoreTimedOut" || error.code === "restoreNotReturned") + ); +} + function AppContent({ useCases, source, @@ -327,6 +335,8 @@ function AppContent({ const [landingVisit, setLandingVisit] = useState(0); const [busy, setBusy] = useState(false); const [authError, setAuthError] = useState(null); + // A session stored that a provider left unanswered: the learner may forget it, as no restore ends it. + const [stuckSession, setStuckSession] = useState(false); // Said in whatever language the user reads by the time it shows, not as an error's message. const authText = authError === SESSION_EXPIRED ? t("app.sessionExpired") : errorText(authError); // A guest on their way to logging in, to keep their study; and one about to discard it. @@ -359,6 +369,9 @@ function AppContent({ setAuthError(e); // They had a session or were logging in: the login is what they need next, not the introduction. setReturning(true); + if (restoreUnanswered(e)) { + setStuckSession(await useCases.hasStoredSession().catch(() => false)); + } } finally { setCheckingSession(false); } @@ -445,6 +458,20 @@ function AppContent({ setLandingVisit((visit) => visit + 1); } + /** Forgets a session whose provider left its restore unanswered: the plain landing page then. */ + async function handleForgetSession() { + setBusy(true); + try { + await useCases.forgetStoredSession(); + setStuckSession(false); + setAuthError(null); + } catch (e) { + setAuthError(e); + } finally { + setBusy(false); + } + } + async function handleLogout() { await useCases.logout(); leaveSession(null); @@ -494,6 +521,13 @@ function AppContent({ {t("app.backToProvider")}

)} + {stuckSession && restoreUnanswered(authError) && ( +
+ +
+ )}