From 54e20a912fc6ef6fed7760ca013eb95d1db2f033 Mon Sep 17 00:00:00 2001 From: rdlabo Date: Sat, 25 Jul 2026 17:10:50 +0900 Subject: [PATCH 1/3] fix(kit): preserve offline scopes across auth recovery --- .../lib/offline-coordinator.service.spec.ts | 55 +++++++++++++++++++ .../src/lib/offline-session.service.spec.ts | 10 ++++ .../src/lib/offline-session.service.ts | 2 +- .../src/lib/auth/auth-access.service.spec.ts | 35 ++++++++++++ .../kit/src/lib/auth/auth-access.service.ts | 13 ++++- projects/kit/src/lib/auth/auth-guards.spec.ts | 29 ++++++++++ projects/kit/src/lib/auth/auth-guards.ts | 3 +- 7 files changed, 143 insertions(+), 4 deletions(-) diff --git a/projects/kit/offline/src/lib/offline-coordinator.service.spec.ts b/projects/kit/offline/src/lib/offline-coordinator.service.spec.ts index 57bd9b3..669291e 100644 --- a/projects/kit/offline/src/lib/offline-coordinator.service.spec.ts +++ b/projects/kit/offline/src/lib/offline-coordinator.service.spec.ts @@ -162,4 +162,59 @@ describe('OfflineCoordinatorService', () => { await expect(newActivation).resolves.toBe(true); expect(sessionState.userId).toBe(9); }); + + it('preserves user scope 0 from remote activation through the first pull', async () => { + let lastUserId: number | null = null; + let manifest: OfflineSessionManifest | null = null; + const pull = vi.fn(async (_scope: { userId: number; groupId: number }) => undefined); + const repository = { + initialize: vi.fn(async () => undefined), + getLastUserId: vi.fn(async () => lastUserId), + setLastUserId: vi.fn(async (userId: number) => { + lastUserId = userId; + }), + getSessionManifest: vi.fn(async () => manifest), + putSessionManifest: vi.fn(async (_userId: number, value: OfflineSessionManifest) => { + manifest = structuredClone(value); + }), + clearUser: vi.fn(async () => undefined), + clearGroup: vi.fn(async () => undefined), + }; + const network = { + state: signal('connected'), + initialize: vi.fn(async () => undefined), + }; + const sync = { + syncState: signal('idle'), + pendingCount: signal(0), + conflicts: signal([]), + initialize: vi.fn(async () => undefined), + resetSession: vi.fn(async () => undefined), + revokeSession: vi.fn(), + refreshSession: vi.fn(async () => { + const remote = await TestBed.inject(OfflineSessionService).getSession(); + await Promise.all((remote?.scopes ?? []).map((scope) => pull(scope))); + }), + refreshLocalSession: vi.fn(async () => undefined), + discardAllPending: vi.fn(async () => undefined), + flush: vi.fn(async () => undefined), + }; + TestBed.resetTestingModule(); + TestBed.configureTestingModule({ + providers: [ + OfflineCoordinatorService, + OfflineSessionService, + { provide: OFFLINE_REPOSITORY, useValue: repository }, + { provide: OfflineNetworkService, useValue: network }, + { provide: OfflineSyncService, useValue: sync }, + ], + }); + const coordinator = TestBed.inject(OfflineCoordinatorService); + + await expect(coordinator.prepareRemoteSession(7, [0], 'subject')).resolves.toBe(true); + await coordinator.resumeRemoteSession(); + + expect(manifest).toMatchObject({ userId: 7, scopeIds: [0], authSubject: 'subject' }); + expect(pull).toHaveBeenCalledWith({ userId: 7, groupId: 0 }); + }); }); diff --git a/projects/kit/offline/src/lib/offline-session.service.spec.ts b/projects/kit/offline/src/lib/offline-session.service.spec.ts index 3e7c3d8..798724b 100644 --- a/projects/kit/offline/src/lib/offline-session.service.spec.ts +++ b/projects/kit/offline/src/lib/offline-session.service.spec.ts @@ -98,6 +98,16 @@ describe('OfflineSessionService shared-device boundary', () => { expect(service.activeManifest()).toMatchObject({ userId: 20, authSubject: 'uid-B' }); }); + it('user-scoped replicaのscope 0をmanifestとremote sessionに保持する', async () => { + await service.activateSession(10, [0], 'uid-A'); + + expect(service.activeManifest()).toMatchObject({ userId: 10, scopeIds: [0], authSubject: 'uid-A' }); + await expect(service.getSession()).resolves.toEqual({ + userId: 10, + scopes: [{ userId: 10, groupId: 0 }], + }); + }); + it('同じuserIdでもauthSubjectが変わると旧主体の全scopeを継承しない', async () => { await service.initialize(); await service.activateSession(10, [2], 'uid-B'); diff --git a/projects/kit/offline/src/lib/offline-session.service.ts b/projects/kit/offline/src/lib/offline-session.service.ts index 55eb9e3..eea455c 100644 --- a/projects/kit/offline/src/lib/offline-session.service.ts +++ b/projects/kit/offline/src/lib/offline-session.service.ts @@ -53,7 +53,7 @@ export class OfflineSessionService { ): Promise { await this.initialize(); if (lease && !lease.isCurrent()) return false; - const normalizedScopeIds = [...new Set(scopeIds)].filter((id) => id !== 0).sort((a, b) => a - b); + const normalizedScopeIds = [...new Set(scopeIds)].sort((a, b) => a - b); const previousUserId = await this.#repository.getLastUserId(); if (lease && !lease.isCurrent()) return false; let previous = previousUserId === userId ? ((await this.#repository.getSessionManifest(userId)) ?? null) : null; diff --git a/projects/kit/src/lib/auth/auth-access.service.spec.ts b/projects/kit/src/lib/auth/auth-access.service.spec.ts index 6dfd49f..2630626 100644 --- a/projects/kit/src/lib/auth/auth-access.service.spec.ts +++ b/projects/kit/src/lib/auth/auth-access.service.spec.ts @@ -61,6 +61,41 @@ describe('KitAuthRecoveryService', () => { expect(reauthenticate).toHaveBeenCalledOnce(); }); + it('invalidates the post-grant resume lease when access is revoked during recovery', async () => { + let markResumeStarted!: () => void; + let releaseResume!: () => void; + const resumeStarted = new Promise((resolve) => { + markResumeStarted = resolve; + }); + const resumeGate = new Promise((resolve) => { + releaseResume = resolve; + }); + const userVisibleEffect = vi.fn(); + const { access, recovery } = setup({ + remoteRecovery: { + availability: () => new Subject(), + reauthenticate: async () => ({ + activate: async () => true, + resume: async (lease) => { + markResumeStarted(); + await resumeGate; + if (lease?.isCurrent()) userVisibleEffect(); + }, + }), + }, + }); + access.grantLocal(); + + const pending = recovery.recover(); + await resumeStarted; + access.clear(); + releaseResume(); + await pending; + + expect(userVisibleEffect).not.toHaveBeenCalled(); + expect(access.mode).toBe('none'); + }); + it('coalesces concurrent recovery attempts into one flight', async () => { let resolveRecovery: ((value: false) => void) | undefined; const reauthenticate = vi.fn( diff --git a/projects/kit/src/lib/auth/auth-access.service.ts b/projects/kit/src/lib/auth/auth-access.service.ts index 3cbd38d..39d6581 100644 --- a/projects/kit/src/lib/auth/auth-access.service.ts +++ b/projects/kit/src/lib/auth/auth-access.service.ts @@ -26,7 +26,15 @@ export interface KitRemoteAccessRecovery { * @returns `false` when a newer logout or identity transition superseded this activation. */ activate(lease: KitAuthAccessLease): Promise; - resume(): Promise; + /** + * Resume remote-only work after access has been published. + * + * @param lease - Post-grant lease supplied by the kit. Check it after every await before + * navigation or other user-visible side effects, because logout or a newer identity transition + * may supersede this resume while transport work is settling. Optional for source compatibility + * with callers that manually resumed a recovery result before leases were introduced. + */ + resume(lease?: KitAuthAccessLease): Promise; } /** Recovery-specific authentication configuration consumed by {@link KitAuthRecoveryService}. */ @@ -231,8 +239,9 @@ export class KitAuthRecoveryService { } this.#clearRetry(); this.#access.grantRemote(); + const resumeLease = this.#access.beginTransition(); expectedRevision = this.#access.revision; - await result.resume(); + await result.resume(resumeLease); } catch (error) { if (this.#destroyed || !isCurrent()) return; if (isExplicitAuthDenial(error)) { diff --git a/projects/kit/src/lib/auth/auth-guards.spec.ts b/projects/kit/src/lib/auth/auth-guards.spec.ts index b526beb..c43d4ce 100644 --- a/projects/kit/src/lib/auth/auth-guards.spec.ts +++ b/projects/kit/src/lib/auth/auth-guards.spec.ts @@ -234,6 +234,35 @@ describe('kitRequireAuthorizedGuard', () => { expect(TestBed.inject(KitAuthAccessService).mode).toBe('remote'); }); + it("'user' → invalidates the post-grant resume lease before stale user-visible effects", async () => { + let markResumeStarted!: () => void; + let releaseResume!: () => void; + const resumeStarted = new Promise((resolve) => { + markResumeStarted = resolve; + }); + const resumeGate = new Promise((resolve) => { + releaseResume = resolve; + }); + const navigateAfterResume = vi.fn(); + const onAuthorized = vi.fn(async () => ({ + activate: async () => true, + resume: async (lease?: KitAuthAccessLease) => { + markResumeStarted(); + await resumeGate; + if (lease?.isCurrent()) navigateAfterResume(); + }, + })); + setup('user', { onAuthorized }); + + const pending = runGuard(TestBed.runInInjectionContext(() => kitRequireAuthorizedGuard(routeStub, stateStub))); + await resumeStarted; + TestBed.inject(KitAuthAccessService).clear(); + releaseResume(); + + await expect(pending).resolves.toBe(false); + expect(navigateAfterResume).not.toHaveBeenCalled(); + }); + it("'user' → keeps verified remote access when only phased resume loses transport", async () => { const networkError = { status: 0 }; const onAuthorized = vi.fn(async () => ({ diff --git a/projects/kit/src/lib/auth/auth-guards.ts b/projects/kit/src/lib/auth/auth-guards.ts index a94e7a5..5d709b2 100644 --- a/projects/kit/src/lib/auth/auth-guards.ts +++ b/projects/kit/src/lib/auth/auth-guards.ts @@ -303,9 +303,10 @@ export const kitRequireAuthorizedGuard: CanActivateFn = (_route, state) => { if (isRemoteAccessActivation(result)) { if (!(await result.activate(lease)) || !lease.isCurrent()) return false; access.grantRemote(); + const resumeLease = access.beginTransition(); const remoteRevision = access.revision; try { - await result.resume(); + await result.resume(resumeLease); } catch (error) { if (access.revision !== remoteRevision) return false; if (isExplicitAuthDenial(error)) { From 6b3dabf67be706e349a71258fcd66162a8fcb85e Mon Sep 17 00:00:00 2001 From: rdlabo Date: Sat, 25 Jul 2026 17:22:38 +0900 Subject: [PATCH 2/3] fix(auth): bind resume lease to remote publication --- .../src/lib/auth/auth-access.service.spec.ts | 25 +++++++++++++++++++ .../kit/src/lib/auth/auth-access.service.ts | 25 +++++++++++-------- projects/kit/src/lib/auth/auth-guards.spec.ts | 22 ++++++++++++++++ projects/kit/src/lib/auth/auth-guards.ts | 8 +++--- 4 files changed, 65 insertions(+), 15 deletions(-) diff --git a/projects/kit/src/lib/auth/auth-access.service.spec.ts b/projects/kit/src/lib/auth/auth-access.service.spec.ts index 2630626..15e3907 100644 --- a/projects/kit/src/lib/auth/auth-access.service.spec.ts +++ b/projects/kit/src/lib/auth/auth-access.service.spec.ts @@ -96,6 +96,31 @@ describe('KitAuthRecoveryService', () => { expect(access.mode).toBe('none'); }); + it('does not reclaim a transition started by a synchronous remote-mode subscriber', async () => { + const userVisibleEffect = vi.fn(); + const { access, recovery } = setup({ + remoteRecovery: { + availability: () => new Subject(), + reauthenticate: async () => ({ + activate: async () => true, + resume: async (lease) => { + if (lease?.isCurrent()) userVisibleEffect(); + }, + }), + }, + }); + access.grantLocal(); + const subscription = access.mode$.subscribe((mode) => { + if (mode === 'remote') access.clear(); + }); + + await recovery.recover(); + subscription.unsubscribe(); + + expect(userVisibleEffect).not.toHaveBeenCalled(); + expect(access.mode).toBe('none'); + }); + it('coalesces concurrent recovery attempts into one flight', async () => { let resolveRecovery: ((value: false) => void) | undefined; const reauthenticate = vi.fn( diff --git a/projects/kit/src/lib/auth/auth-access.service.ts b/projects/kit/src/lib/auth/auth-access.service.ts index 39d6581..2e3e9a2 100644 --- a/projects/kit/src/lib/auth/auth-access.service.ts +++ b/projects/kit/src/lib/auth/auth-access.service.ts @@ -107,9 +107,14 @@ export class KitAuthAccessService { this.#publish('local'); } - /** Publish a remotely authenticated session. */ - grantRemote(): void { - this.#publish('remote'); + /** + * Publish a remotely authenticated session and return the lease that owns that publication. + * + * The revision is fixed before synchronous `mode$` subscribers run. A subscriber that starts a + * newer logout or identity transition therefore makes the returned lease stale. + */ + grantRemote(): KitAuthAccessLease { + return this.#publish('remote'); } /** Revoke both local and remote access. */ @@ -117,9 +122,11 @@ export class KitAuthAccessService { this.#publish('none'); } - #publish(mode: KitAuthAccessMode): void { + #publish(mode: KitAuthAccessMode): KitAuthAccessLease { this.#revision += 1; + const revision = this.#revision; this.#mode.next(mode); + return { isCurrent: () => this.#revision === revision }; } } @@ -219,8 +226,8 @@ export class KitAuthRecoveryService { const recovery = this.#config.remoteRecovery; if (this.#destroyed || !recovery || this.#access.mode !== 'local') return; const lease = this.#access.beginTransition(); - let expectedRevision = this.#access.revision; - const isCurrent = (): boolean => this.#access.revision === expectedRevision; + let currentLease = lease; + const isCurrent = (): boolean => currentLease.isCurrent(); try { const result = await recovery.reauthenticate(lease); if (this.#destroyed || !isCurrent() || this.#access.mode !== 'local') return; @@ -238,10 +245,8 @@ export class KitAuthRecoveryService { return; } this.#clearRetry(); - this.#access.grantRemote(); - const resumeLease = this.#access.beginTransition(); - expectedRevision = this.#access.revision; - await result.resume(resumeLease); + currentLease = this.#access.grantRemote(); + await result.resume(currentLease); } catch (error) { if (this.#destroyed || !isCurrent()) return; if (isExplicitAuthDenial(error)) { diff --git a/projects/kit/src/lib/auth/auth-guards.spec.ts b/projects/kit/src/lib/auth/auth-guards.spec.ts index c43d4ce..73f53b5 100644 --- a/projects/kit/src/lib/auth/auth-guards.spec.ts +++ b/projects/kit/src/lib/auth/auth-guards.spec.ts @@ -263,6 +263,28 @@ describe('kitRequireAuthorizedGuard', () => { expect(navigateAfterResume).not.toHaveBeenCalled(); }); + it("'user' → does not reclaim a transition started by a synchronous remote-mode subscriber", async () => { + const navigateAfterResume = vi.fn(); + const onAuthorized = vi.fn(async () => ({ + activate: async () => true, + resume: async (lease?: KitAuthAccessLease) => { + if (lease?.isCurrent()) navigateAfterResume(); + }, + })); + setup('user', { onAuthorized }); + const access = TestBed.inject(KitAuthAccessService); + const subscription = access.mode$.subscribe((mode) => { + if (mode === 'remote') access.clear(); + }); + + const result = await runGuard(TestBed.runInInjectionContext(() => kitRequireAuthorizedGuard(routeStub, stateStub))); + subscription.unsubscribe(); + + expect(result).toBe(false); + expect(navigateAfterResume).not.toHaveBeenCalled(); + expect(access.mode).toBe('none'); + }); + it("'user' → keeps verified remote access when only phased resume loses transport", async () => { const networkError = { status: 0 }; const onAuthorized = vi.fn(async () => ({ diff --git a/projects/kit/src/lib/auth/auth-guards.ts b/projects/kit/src/lib/auth/auth-guards.ts index 5d709b2..82f010b 100644 --- a/projects/kit/src/lib/auth/auth-guards.ts +++ b/projects/kit/src/lib/auth/auth-guards.ts @@ -302,20 +302,18 @@ export const kitRequireAuthorizedGuard: CanActivateFn = (_route, state) => { if (!lease.isCurrent()) return false; if (isRemoteAccessActivation(result)) { if (!(await result.activate(lease)) || !lease.isCurrent()) return false; - access.grantRemote(); - const resumeLease = access.beginTransition(); - const remoteRevision = access.revision; + const resumeLease = access.grantRemote(); try { await result.resume(resumeLease); } catch (error) { - if (access.revision !== remoteRevision) return false; + if (!resumeLease.isCurrent()) return false; if (isExplicitAuthDenial(error)) { access.clear(); throw error; } if (!isUnavailableError?.(error)) throw error; } - return access.revision === remoteRevision; + return resumeLease.isCurrent(); } if (result === true) access.grantRemote(); else access.clear(); From 5ff1a005976aa7e4fe5c116bd64b910077159b9a Mon Sep 17 00:00:00 2001 From: rdlabo Date: Sat, 25 Jul 2026 17:24:34 +0900 Subject: [PATCH 3/3] fix(auth): skip stale remote resume --- projects/kit/src/lib/auth/auth-access.service.spec.ts | 9 ++++++--- projects/kit/src/lib/auth/auth-access.service.ts | 1 + projects/kit/src/lib/auth/auth-guards.spec.ts | 8 +++++--- projects/kit/src/lib/auth/auth-guards.ts | 1 + 4 files changed, 13 insertions(+), 6 deletions(-) diff --git a/projects/kit/src/lib/auth/auth-access.service.spec.ts b/projects/kit/src/lib/auth/auth-access.service.spec.ts index 15e3907..658c715 100644 --- a/projects/kit/src/lib/auth/auth-access.service.spec.ts +++ b/projects/kit/src/lib/auth/auth-access.service.spec.ts @@ -6,6 +6,7 @@ import { KIT_AUTH_RECOVERY_CONFIG, KitAuthAccessService, KitAuthRecoveryService, + type KitAuthAccessLease, type KitAuthRecoveryConfig, type KitRemoteAccessRecovery, } from './auth-access.service'; @@ -98,14 +99,15 @@ describe('KitAuthRecoveryService', () => { it('does not reclaim a transition started by a synchronous remote-mode subscriber', async () => { const userVisibleEffect = vi.fn(); + const resume = vi.fn(async (lease?: KitAuthAccessLease) => { + if (lease?.isCurrent()) userVisibleEffect(); + }); const { access, recovery } = setup({ remoteRecovery: { availability: () => new Subject(), reauthenticate: async () => ({ activate: async () => true, - resume: async (lease) => { - if (lease?.isCurrent()) userVisibleEffect(); - }, + resume, }), }, }); @@ -117,6 +119,7 @@ describe('KitAuthRecoveryService', () => { await recovery.recover(); subscription.unsubscribe(); + expect(resume).not.toHaveBeenCalled(); expect(userVisibleEffect).not.toHaveBeenCalled(); expect(access.mode).toBe('none'); }); diff --git a/projects/kit/src/lib/auth/auth-access.service.ts b/projects/kit/src/lib/auth/auth-access.service.ts index 2e3e9a2..c82a79f 100644 --- a/projects/kit/src/lib/auth/auth-access.service.ts +++ b/projects/kit/src/lib/auth/auth-access.service.ts @@ -246,6 +246,7 @@ export class KitAuthRecoveryService { } this.#clearRetry(); currentLease = this.#access.grantRemote(); + if (!currentLease.isCurrent()) return; await result.resume(currentLease); } catch (error) { if (this.#destroyed || !isCurrent()) return; diff --git a/projects/kit/src/lib/auth/auth-guards.spec.ts b/projects/kit/src/lib/auth/auth-guards.spec.ts index 73f53b5..f00d729 100644 --- a/projects/kit/src/lib/auth/auth-guards.spec.ts +++ b/projects/kit/src/lib/auth/auth-guards.spec.ts @@ -265,11 +265,12 @@ describe('kitRequireAuthorizedGuard', () => { it("'user' → does not reclaim a transition started by a synchronous remote-mode subscriber", async () => { const navigateAfterResume = vi.fn(); + const resume = vi.fn(async (lease?: KitAuthAccessLease) => { + if (lease?.isCurrent()) navigateAfterResume(); + }); const onAuthorized = vi.fn(async () => ({ activate: async () => true, - resume: async (lease?: KitAuthAccessLease) => { - if (lease?.isCurrent()) navigateAfterResume(); - }, + resume, })); setup('user', { onAuthorized }); const access = TestBed.inject(KitAuthAccessService); @@ -281,6 +282,7 @@ describe('kitRequireAuthorizedGuard', () => { subscription.unsubscribe(); expect(result).toBe(false); + expect(resume).not.toHaveBeenCalled(); expect(navigateAfterResume).not.toHaveBeenCalled(); expect(access.mode).toBe('none'); }); diff --git a/projects/kit/src/lib/auth/auth-guards.ts b/projects/kit/src/lib/auth/auth-guards.ts index 82f010b..3c3fab9 100644 --- a/projects/kit/src/lib/auth/auth-guards.ts +++ b/projects/kit/src/lib/auth/auth-guards.ts @@ -303,6 +303,7 @@ export const kitRequireAuthorizedGuard: CanActivateFn = (_route, state) => { if (isRemoteAccessActivation(result)) { if (!(await result.activate(lease)) || !lease.isCurrent()) return false; const resumeLease = access.grantRemote(); + if (!resumeLease.isCurrent()) return false; try { await result.resume(resumeLease); } catch (error) {