diff --git a/apps/web/src/app/server-storage/server-sign-in.component.spec.ts b/apps/web/src/app/server-storage/server-sign-in.component.spec.ts index 82a28c023..f92c57529 100644 --- a/apps/web/src/app/server-storage/server-sign-in.component.spec.ts +++ b/apps/web/src/app/server-storage/server-sign-in.component.spec.ts @@ -13,7 +13,7 @@ const TOKEN_KEY = 'iptvnator-server:access-token'; describe('ServerSignInComponent', () => { let fixture: ComponentFixture; let component: ServerSignInComponent; - const session = { fetchStatus: jest.fn() }; + const session = { fetchStatus: jest.fn(), ensureVerified: jest.fn() }; beforeEach(async () => { await TestBed.configureTestingModule({ @@ -152,4 +152,16 @@ describe('ServerSignInComponent', () => { expect(localStorage.getItem(TOKEN_KEY)).toBeNull(); expect(feedbackText()).toBe('SERVER_STORAGE.SIGN_IN_STORAGE'); }); + + it('sends the token only after the server proved to be the remembered one', async () => { + session.ensureVerified.mockRejectedValue(new Error('another server')); + + component.edit('right'); + await component.submit(new Event('submit')); + fixture.detectChanges(); + + expect(session.fetchStatus).not.toHaveBeenCalled(); + expect(localStorage.getItem(TOKEN_KEY)).toBeNull(); + expect(feedbackText()).toBe('SERVER_STORAGE.SIGN_IN_UNREACHABLE'); + }); }); diff --git a/apps/web/src/app/server-storage/server-sign-in.component.ts b/apps/web/src/app/server-storage/server-sign-in.component.ts index d05134778..b7a93d618 100644 --- a/apps/web/src/app/server-storage/server-sign-in.component.ts +++ b/apps/web/src/app/server-storage/server-sign-in.component.ts @@ -56,6 +56,17 @@ export class ServerSignInComponent { if (!token || this.busy()) { return; } + this.busy.set(true); + this.feedback.set(null); + try { + // A page started from memory checks the server first: the token + // must reach only the one it is meant for. + await this.session.ensureVerified(); + } catch { + this.feedback.set('unreachable'); + this.busy.set(false); + return; + } // Every request and the reload read the token from storage, and the // next start keeps it only for the server it remembers. if ( @@ -64,10 +75,9 @@ export class ServerSignInComponent { ) { clearServerAccessToken(); this.feedback.set('storage'); + this.busy.set(false); return; } - this.busy.set(true); - this.feedback.set(null); try { await this.session.fetchStatus(); this.openWorkspace(); diff --git a/docs/architecture/pwa-self-hosted.md b/docs/architecture/pwa-self-hosted.md index bfa93ce24..016482a68 100644 --- a/docs/architecture/pwa-self-hosted.md +++ b/docs/architecture/pwa-self-hosted.md @@ -304,7 +304,9 @@ settings and guards read them in every build. when the backend answers without server storage, or as another server or database (backend URL or instance id) than the one it remembers; sign-in therefore remembers the server along with the token, or reports storage - trouble. Both turn + trouble. A page started from memory first checks that the instance at the + address is the remembered one (`ensureVerified`), and reloads if not, + before sign-in sends the token. Both turn the guard's 401 (`isServerAccessChallenge`) into `reportAccessDenied()`, so a token changed on the server sends an open page back to sign-in; a late 401 for a token replaced since (a new sign-in) changes nothing. diff --git a/libs/services/src/lib/server-storage-status.service.ts b/libs/services/src/lib/server-storage-status.service.ts index a670dd369..06bb7a982 100644 --- a/libs/services/src/lib/server-storage-status.service.ts +++ b/libs/services/src/lib/server-storage-status.service.ts @@ -43,6 +43,11 @@ export class ServerStorageStatusService { this.stateSignal.set('online'); } + /** The instance at the address turned out to be the remembered one. */ + markVerified(): void { + this.verified = true; + } + /** * The stored token, only while server mode runs on an instance this * start verified: a token must never reach a server it was not given diff --git a/libs/services/src/lib/server-storage/server-storage-session.spec.ts b/libs/services/src/lib/server-storage/server-storage-session.spec.ts index 9a9925c4c..e796501e3 100644 --- a/libs/services/src/lib/server-storage/server-storage-session.spec.ts +++ b/libs/services/src/lib/server-storage/server-storage-session.spec.ts @@ -204,6 +204,38 @@ describe('ServerStorageSession', () => { expect(session.userData.hasUnsentWrites()).toBe(true); }); + it('checks the instance before a token may go out', async () => { + const { session, status } = setup('token', true); + const reload = jest + .spyOn( + session as unknown as { reloadPage(): void }, + 'reloadPage' + ) + .mockImplementation(() => undefined); + await session.start(); + localStorage.setItem(SERVER_ACCESS_TOKEN_STORAGE_KEY, 't'); + expect(status.accessToken()).toBeNull(); + + fetchMock.mockResolvedValue({ + status: 200, + ok: true, + headers: new Headers(), + json: async () => ({ instanceId: 'other' }), + }); + await expect(session.ensureVerified()).rejects.toThrow(); + expect(reload).toHaveBeenCalled(); + expect(status.accessToken()).toBeNull(); + + fetchMock.mockResolvedValue({ + status: 200, + ok: true, + headers: new Headers(), + json: async () => ({ instanceId: 'i-1' }), + }); + await session.ensureVerified(); + expect(status.accessToken()).toBe('t'); + }); + it('never reloads after a normal start', async () => { const { session } = setup('none'); const reload = jest diff --git a/libs/services/src/lib/server-storage/server-storage-session.ts b/libs/services/src/lib/server-storage/server-storage-session.ts index f694048d6..34583ae51 100644 --- a/libs/services/src/lib/server-storage/server-storage-session.ts +++ b/libs/services/src/lib/server-storage/server-storage-session.ts @@ -126,6 +126,24 @@ export class ServerStorageSession { } } + /** + * Before a token goes out from a page started from memory: the instance + * answering at the address must be the remembered one. Another one + * reloads the page, which starts against it. + */ + async ensureVerified(): Promise { + if (!this.config.startedOffline) { + return; + } + const { instanceId } = + await this.api.getJson('/instance'); + if (instanceId !== this.config.instance.instanceId) { + this.reloadPage(); + throw new Error('Another server answers at this address.'); + } + this.status.markVerified(); + } + fetchStatus(): Promise { return this.api.getJson( '/instance/status'