fix(server-storage): check a remembered instance before sign-in sends the token

A page started from memory (startedOffline) withholds the token until the
instance is verified, so sign-in on that page sent no token and the
server's 401 cleared the right one. Sign-in now first calls
ServerStorageSession.ensureVerified(): it reads /instance without a
token, marks the status verified when the instance id matches, and
reloads the page when another server answers at the address.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
4grayandClaude Opus 5.5 committed 2026-10-13 20:51:04 +02:00
1 parent b8838c1c09
commit a94673b4ab
6 files changed
+83 -4

No files matched your search

@@ -13,7 +13,7 @@ const TOKEN_KEY = 'iptvnator-server:access-token';
describe('ServerSignInComponent', () => {
let fixture: ComponentFixture<ServerSignInComponent>;
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');
});
});
@@ -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();
+3 -1
View File
@@ -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.
@@ -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
@@ -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
@@ -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<void> {
if (!this.config.startedOffline) {
return;
}
const { instanceId } =
await this.api.getJson<ServerInstanceInfo>('/instance');
if (instanceId !== this.config.instance.instanceId) {
this.reloadPage();
throw new Error('Another server answers at this address.');
}
this.status.markVerified();
}
fetchStatus(): Promise<ServerStorageStatusResponse> {
return this.api.getJson<ServerStorageStatusResponse>(
'/instance/status'