Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 31 additions & 20 deletions docs/authentication.md
Original file line number Diff line number Diff line change
Expand Up @@ -303,40 +303,51 @@ 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
it, with no answer on the address, doesn't restore silently: the
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

Expand Down
5 changes: 3 additions & 2 deletions docs/solid-memo-client.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions packages/application/src/ports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,14 @@ export interface SessionGateway {
*/
loginWithIssuer(oidcIssuer: string): Promise<void>;
logout(): Promise<void>;
/** Whether a session is stored in this browser for a restore to try. */
hasStoredSession(): Promise<boolean>;
/**
* 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<void>;
}

/** Driven port: read access to WebID documents. */
Expand Down
19 changes: 19 additions & 0 deletions packages/application/src/session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<typeof makeDeps>) {
Expand Down
16 changes: 16 additions & 0 deletions packages/application/src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,6 +80,14 @@ export interface SessionUseCases {
loginWithProvider(oidcIssuer: string): Promise<void>;
/** Ends the session in this tab, and tells this browser's other tabs to end theirs. */
logout(): Promise<void>;
/** Whether a session is stored in this browser for a restore to try. */
hasStoredSession(): Promise<boolean>;
/**
* 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<void>;
/** Subscribe to session expiry; returns an unsubscribe function. */
onSessionExpired(listener: () => void): () => void;
/**
Expand Down Expand Up @@ -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.
Expand Down
2 changes: 2 additions & 0 deletions packages/application/src/testing/fakePorts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()),
};
Expand Down
2 changes: 1 addition & 1 deletion packages/solid-memo-client/src/session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
2 changes: 1 addition & 1 deletion packages/solid-memo-client/src/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down
7 changes: 4 additions & 3 deletions packages/solid/src/client/interface.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,9 +210,10 @@ export interface SessionClient {
*/
sessionIssuer(): Promise<string | null>;
/**
* 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<void>;
/**
Expand Down
14 changes: 8 additions & 6 deletions packages/solid/src/client/session.library.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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();
});
});
Loading
Loading