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
55 changes: 55 additions & 0 deletions projects/kit/offline/src/lib/offline-coordinator.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
});
});
10 changes: 10 additions & 0 deletions projects/kit/offline/src/lib/offline-session.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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');
Expand Down
2 changes: 1 addition & 1 deletion projects/kit/offline/src/lib/offline-session.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ export class OfflineSessionService {
): Promise<void | boolean> {
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<OfflineSessionManifest>(userId)) ?? null) : null;
Expand Down
63 changes: 63 additions & 0 deletions projects/kit/src/lib/auth/auth-access.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
KIT_AUTH_RECOVERY_CONFIG,
KitAuthAccessService,
KitAuthRecoveryService,
type KitAuthAccessLease,
type KitAuthRecoveryConfig,
type KitRemoteAccessRecovery,
} from './auth-access.service';
Expand Down Expand Up @@ -61,6 +62,68 @@ 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<void>((resolve) => {
markResumeStarted = resolve;
});
const resumeGate = new Promise<void>((resolve) => {
releaseResume = resolve;
});
const userVisibleEffect = vi.fn();
const { access, recovery } = setup({
remoteRecovery: {
availability: () => new Subject<boolean>(),
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('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<boolean>(),
reauthenticate: async () => ({
activate: async () => true,
resume,
}),
},
});
access.grantLocal();
const subscription = access.mode$.subscribe((mode) => {
if (mode === 'remote') access.clear();
});

await recovery.recover();
subscription.unsubscribe();

expect(resume).not.toHaveBeenCalled();
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(
Expand Down
35 changes: 25 additions & 10 deletions projects/kit/src/lib/auth/auth-access.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,15 @@ export interface KitRemoteAccessRecovery {
* @returns `false` when a newer logout or identity transition superseded this activation.
*/
activate(lease: KitAuthAccessLease): Promise<boolean>;
resume(): Promise<void>;
/**
* 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<void>;
}

/** Recovery-specific authentication configuration consumed by {@link KitAuthRecoveryService}. */
Expand Down Expand Up @@ -99,19 +107,26 @@ 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. */
clear(): void {
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 };
}
}

Expand Down Expand Up @@ -211,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;
Expand All @@ -230,9 +245,9 @@ export class KitAuthRecoveryService {
return;
}
this.#clearRetry();
this.#access.grantRemote();
expectedRevision = this.#access.revision;
await result.resume();
currentLease = this.#access.grantRemote();
if (!currentLease.isCurrent()) return;
await result.resume(currentLease);
} catch (error) {
if (this.#destroyed || !isCurrent()) return;
if (isExplicitAuthDenial(error)) {
Expand Down
53 changes: 53 additions & 0 deletions projects/kit/src/lib/auth/auth-guards.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -234,6 +234,59 @@ 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<void>((resolve) => {
markResumeStarted = resolve;
});
const resumeGate = new Promise<void>((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' → 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,
}));
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(resume).not.toHaveBeenCalled();
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 () => ({
Expand Down
10 changes: 5 additions & 5 deletions projects/kit/src/lib/auth/auth-guards.ts
Original file line number Diff line number Diff line change
Expand Up @@ -302,19 +302,19 @@ 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 remoteRevision = access.revision;
const resumeLease = access.grantRemote();
if (!resumeLease.isCurrent()) return false;
try {
await result.resume();
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();
Expand Down