From d8d4458a85a4b22dcee20c324622a80be1f958ba Mon Sep 17 00:00:00 2001 From: rdlabo Date: Sat, 25 Jul 2026 21:37:42 +0900 Subject: [PATCH] feat(offline): bridge authenticated sessions --- projects/kit/README.md | 53 +-- .../src/lib/offline-auth-bridge.spec.ts | 313 ++++++++++++++++++ .../offline/src/lib/offline-auth-bridge.ts | 169 ++++++++++ projects/kit/offline/src/public-api.ts | 1 + 4 files changed, 510 insertions(+), 26 deletions(-) create mode 100644 projects/kit/offline/src/lib/offline-auth-bridge.spec.ts create mode 100644 projects/kit/offline/src/lib/offline-auth-bridge.ts diff --git a/projects/kit/README.md b/projects/kit/README.md index 315ffb5..d447b70 100644 --- a/projects/kit/README.md +++ b/projects/kit/README.md @@ -408,36 +408,37 @@ step immediately invalidates every in-flight auth lease; the second serializes c already in progress and removes the manifest and user replica. ```ts -provideKitAuth(() => { - const offline = inject(OfflineCoordinatorService); - return { - authState: () => auth.state$, - onAuthorized: async () => { - const session = await auth.exchangeCredential(); - return { - activate: (lease) => offline.prepareRemoteSession(session.userId, session.groupIds, session.subject, lease), - resume: () => offline.resumeRemoteSession(), - }; +import { createOfflineAuthBridge, isOfflineFallbackError } from '@rdlabo/ionic-angular-kit/offline'; + +provideKitAuth(() => ({ + authState: () => auth.state$, + ...createOfflineAuthBridge({ + exchange: async (ctx) => { + const remote = await auth.exchangeCredential(ctx); + const authSubject = auth.currentSubject(); + return remote && authSubject + ? { ...remote, authSubject } + : false; }, - onUnavailable: async (_state, _error, lease) => (await offline.activateOfflineSession(auth.currentSubject(), lease)) !== null, + currentAuthSubject: () => auth.currentSubject(), isUnavailableError: isOfflineFallbackError, - remoteRecovery: { - availability: () => auth.authorityAvailable$, - reauthenticate: async () => { - const session = await auth.tryExchangeCredential(); - return session - ? { - activate: (lease) => offline.prepareRemoteSession(session.userId, session.groupIds, session.subject, lease), - resume: () => offline.resumeRemoteSession(), - } - : false; - }, - }, - redirects, - }; -}); + availability: () => auth.authorityAvailable$, + }), + redirects, +})); ``` +`createOfflineAuthBridge` owns `exchange` → `prepareRemoteSession` → kit `grantRemote` → `resumeRemoteSession` +ordering via `KitRemoteAccessRecovery`. Product code keeps consent, error UI, and credential mapping inside +`exchange(context)` (`phase: 'authorize' | 'recover'`, optional route state, lease). Return `null`/`false` to decline +without throwing; thrown errors are never swallowed and remain available to the guard's unavailable/denial +classification. The returned identity requires a positive `userId`, non-negative numeric scope ids (including the +current user-scope compatibility value `0`), and a non-empty `authSubject`. The bridge compares that subject before +session activation, immediately before the coordinator commits through a composite lease, and around remote resume. +Optional `isIdentityCurrent` can add provider object-identity checks. `onRemoteResumed` receives the identity, phase, +route state, and post-grant lease for safe redirects or other product side effects. Default recovery availability +follows `offline.networkState !== 'offline'`. + Register `offlineInterceptor` before `kitAuthInterceptor`. In local mode the auth interceptor synthesizes a transport-unavailable error before generating credentials or touching the network; the outer offline interceptor may then serve a matched `GET` from the replica. In `none` mode the same request is rejected and no local data is diff --git a/projects/kit/offline/src/lib/offline-auth-bridge.spec.ts b/projects/kit/offline/src/lib/offline-auth-bridge.spec.ts new file mode 100644 index 0000000..09b468f --- /dev/null +++ b/projects/kit/offline/src/lib/offline-auth-bridge.spec.ts @@ -0,0 +1,313 @@ +import { provideZonelessChangeDetection, signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import type { RouterStateSnapshot } from '@angular/router'; +import type { KitAuthAccessLease, KitRemoteAccessRecovery } from '@rdlabo/ionic-angular-kit'; +import { describe, expect, it, vi } from 'vitest'; +import { firstValueFrom, of } from 'rxjs'; +import { createOfflineAuthBridge, type OfflineAuthExchangeContext, type OfflineRemoteIdentity } from './offline-auth-bridge'; +import type { OfflineCoordinatorService } from './offline-coordinator.service'; + +const stateStub = {} as RouterStateSnapshot; +const identity: OfflineRemoteIdentity = { userId: 1, scopeIds: [2], authSubject: 'subject-a' }; + +function assertRecovery(value: unknown): KitRemoteAccessRecovery { + expect(value).toEqual(expect.objectContaining({ activate: expect.any(Function), resume: expect.any(Function) })); + return value as KitRemoteAccessRecovery; +} + +function createLease(initial = true): { lease: KitAuthAccessLease; invalidate: () => void } { + let current = initial; + return { + lease: { isCurrent: () => current }, + invalidate: () => { + current = false; + }, + }; +} + +function setupBridge( + overrides: Partial[0]> & { + exchangeImpl?: (context: OfflineAuthExchangeContext) => Promise; + } = {}, +) { + const order: string[] = []; + const exchangeContexts: OfflineAuthExchangeContext[] = []; + const offline = { + networkState: () => 'online', + prepareRemoteSession: vi.fn(async () => { + order.push('prepare'); + return true; + }), + resumeRemoteSession: vi.fn(async () => { + order.push('resume'); + }), + activateOfflineSession: vi.fn(async () => { + order.push('activate-offline'); + return identity; + }), + } as unknown as OfflineCoordinatorService; + + const bridge = createOfflineAuthBridge({ + offline, + exchange: + overrides.exchangeImpl ?? + (async (context) => { + order.push(`exchange-${context.phase}`); + exchangeContexts.push(context); + return identity; + }), + currentAuthSubject: overrides.currentAuthSubject ?? (() => 'subject-a'), + isUnavailableError: overrides.isUnavailableError ?? (() => true), + availability: overrides.availability ?? (() => of(true)), + isIdentityCurrent: overrides.isIdentityCurrent, + onRemoteResumed: overrides.onRemoteResumed, + retryDelayMs: overrides.retryDelayMs, + }); + + return { bridge, offline, order, exchangeContexts }; +} + +describe('createOfflineAuthBridge', () => { + it('orders exchange, prepareRemoteSession, grant, and resumeRemoteSession', async () => { + const { bridge, offline, order } = setupBridge(); + const { lease } = createLease(); + + const recovery = assertRecovery(await bridge.onAuthorized!(stateStub, lease)); + expect(order).toEqual(['exchange-authorize']); + + await recovery.activate(lease); + expect(order).toEqual(['exchange-authorize', 'prepare']); + expect(offline.prepareRemoteSession).toHaveBeenCalledWith( + 1, + [2], + 'subject-a', + expect.objectContaining({ isCurrent: expect.any(Function) }), + ); + + await recovery.resume(lease); + expect(order).toEqual(['exchange-authorize', 'prepare', 'resume']); + }); + + it('rejects activation when the lease becomes stale after exchange', async () => { + const { lease, invalidate } = createLease(); + const { bridge } = setupBridge({ + exchangeImpl: async () => { + invalidate(); + return identity; + }, + }); + + await expect(bridge.onAuthorized!(stateStub, lease)).resolves.toBe(false); + }); + + it('rejects activation when the identity is replaced before prepareRemoteSession', async () => { + const isIdentityCurrent = vi.fn(() => true); + const { bridge } = setupBridge({ isIdentityCurrent }); + const { lease } = createLease(); + + const recovery = assertRecovery(await bridge.onAuthorized!(stateStub, lease)); + isIdentityCurrent.mockReturnValue(false); + + await expect(recovery.activate(lease)).resolves.toBe(false); + }); + + it('auth subjectが交換後に変わったidentityはprepareしない', async () => { + let subject = 'subject-a'; + const { bridge, offline } = setupBridge({ currentAuthSubject: () => subject }); + const { lease } = createLease(); + + const recovery = assertRecovery(await bridge.onAuthorized!(stateStub, lease)); + subject = 'subject-b'; + + await expect(recovery.activate(lease)).resolves.toBe(false); + expect(offline.prepareRemoteSession).not.toHaveBeenCalled(); + }); + + it('prepareへ渡す複合leaseはidentity replacementをcommit直前にも検知する', async () => { + const isIdentityCurrent = vi.fn(() => true); + const { bridge, offline } = setupBridge({ isIdentityCurrent }); + const { lease } = createLease(); + const recovery = assertRecovery(await bridge.onAuthorized!(stateStub, lease)); + + await recovery.activate(lease); + const identityLease = vi.mocked(offline.prepareRemoteSession).mock.calls[0]?.[3]; + isIdentityCurrent.mockReturnValue(false); + + expect(identityLease?.isCurrent()).toBe(false); + }); + + it('activates offline fallback with the current auth subject', async () => { + const { bridge, offline } = setupBridge({ currentAuthSubject: () => 'subject-a' }); + const { lease } = createLease(); + + await expect(bridge.onUnavailable!(stateStub, { status: 0 }, lease)).resolves.toBe(true); + expect(offline.activateOfflineSession).toHaveBeenCalledWith('subject-a', lease); + }); + + it('passes recover phase and lease through remote recovery exchange', async () => { + const { bridge, exchangeContexts } = setupBridge(); + const { lease } = createLease(); + + const recovery = await bridge.remoteRecovery!.reauthenticate(lease); + assertRecovery(recovery); + expect(exchangeContexts).toEqual([{ phase: 'recover', state: undefined, lease }]); + }); + + it('returns false from exchange when the product declines credential exchange', async () => { + const { bridge } = setupBridge({ exchangeImpl: async () => false }); + const { lease } = createLease(); + + await expect(bridge.onAuthorized!(stateStub, lease)).resolves.toBe(false); + await expect(bridge.remoteRecovery!.reauthenticate(lease)).resolves.toBe(false); + }); + + it('exchange errorを握りつぶさずguard/recoveryへ伝播する', async () => { + const unavailable = { status: 0 }; + const { bridge } = setupBridge({ + exchangeImpl: () => Promise.reject(unavailable), + }); + const { lease } = createLease(); + + await expect(bridge.onAuthorized!(stateStub, lease)).rejects.toBe(unavailable); + await expect(bridge.remoteRecovery!.reauthenticate(lease)).rejects.toBe(unavailable); + }); + + it('propagates the configured unavailable error classifier', () => { + const classifier = vi.fn((error: unknown) => (error as { status?: number }).status === 0); + const { bridge } = setupBridge({ isUnavailableError: classifier }); + + expect(bridge.isUnavailableError!({ status: 0 })).toBe(true); + expect(bridge.isUnavailableError!({ status: 500 })).toBe(false); + expect(classifier).toHaveBeenCalledTimes(2); + }); + + it('defaults availability to networkState !== offline', async () => { + const offline = { + networkState: signal('offline' as const), + prepareRemoteSession: vi.fn(), + resumeRemoteSession: vi.fn(), + activateOfflineSession: vi.fn(), + } as unknown as OfflineCoordinatorService; + + TestBed.configureTestingModule({ providers: [provideZonelessChangeDetection()] }); + const bridge = TestBed.runInInjectionContext(() => + createOfflineAuthBridge({ + offline, + exchange: async () => identity, + currentAuthSubject: () => null, + isUnavailableError: () => true, + }), + ); + + await expect(firstValueFrom(bridge.remoteRecovery!.availability())).resolves.toBe(false); + TestBed.resetTestingModule(); + }); + + it('uses a custom availability observable when supplied', () => { + const availability = vi.fn(() => of(true)); + const { bridge } = setupBridge({ availability }); + + bridge.remoteRecovery!.availability(); + expect(availability).toHaveBeenCalledOnce(); + }); + + it('skips post-resume hooks when the lease becomes stale during transport resume', async () => { + const onRemoteResumed = vi.fn(async () => undefined); + const { bridge, offline } = setupBridge({ onRemoteResumed }); + const { lease, invalidate } = createLease(); + + offline.resumeRemoteSession = vi.fn(async () => { + invalidate(); + }); + + const recovery = assertRecovery(await bridge.onAuthorized!(stateStub, lease)); + await recovery.activate(lease); + await recovery.resume(lease); + + expect(offline.resumeRemoteSession).toHaveBeenCalledOnce(); + expect(onRemoteResumed).not.toHaveBeenCalled(); + }); + + it('post-grant leaseがresume前に失効していればtransportを開始しない', async () => { + const { bridge, offline } = setupBridge(); + const activationLease = createLease().lease; + const recovery = assertRecovery(await bridge.onAuthorized!(stateStub, activationLease)); + await recovery.activate(activationLease); + const { lease: resumeLease, invalidate } = createLease(); + invalidate(); + + await recovery.resume(resumeLease); + + expect(offline.resumeRemoteSession).not.toHaveBeenCalled(); + }); + + it('transport resume中にauth subjectが変わればproduct hookを実行しない', async () => { + let subject = 'subject-a'; + const onRemoteResumed = vi.fn(async () => undefined); + const { bridge, offline } = setupBridge({ + currentAuthSubject: () => subject, + onRemoteResumed, + }); + offline.resumeRemoteSession = vi.fn(async () => { + subject = 'subject-b'; + }); + const { lease } = createLease(); + const recovery = assertRecovery(await bridge.onAuthorized!(stateStub, lease)); + await recovery.activate(lease); + + await recovery.resume(lease); + + expect(offline.resumeRemoteSession).toHaveBeenCalledOnce(); + expect(onRemoteResumed).not.toHaveBeenCalled(); + }); + + it('resume後のproduct hookへidentityと元のauthorize contextを渡す', async () => { + const onRemoteResumed = vi.fn(async () => undefined); + const { bridge } = setupBridge({ onRemoteResumed }); + const { lease } = createLease(); + + const recovery = assertRecovery(await bridge.onAuthorized!(stateStub, lease)); + await recovery.activate(lease); + await recovery.resume(lease); + + expect(onRemoteResumed).toHaveBeenCalledWith({ phase: 'authorize', state: stateStub, lease, identity }); + }); + + it('product固有identityを型付きのcurrent checkとresume hookへ保持する', async () => { + const authSession = { uid: 'subject-a' }; + const productIdentity = { ...identity, authSession, redirectUrl: '/requested' }; + const navigate = vi.fn(async (_url: string) => undefined); + const offline = { + networkState: signal('online' as const), + prepareRemoteSession: vi.fn(async () => true), + resumeRemoteSession: vi.fn(async () => undefined), + activateOfflineSession: vi.fn(async () => identity), + } as unknown as OfflineCoordinatorService; + const bridge = createOfflineAuthBridge({ + offline, + availability: () => of(true), + exchange: async () => productIdentity, + currentAuthSubject: () => authSession.uid, + isUnavailableError: () => false, + isIdentityCurrent: (current) => current.authSession === authSession, + onRemoteResumed: async ({ identity: current }) => navigate(current.redirectUrl), + }); + const { lease } = createLease(); + + const recovery = assertRecovery(await bridge.onAuthorized!(stateStub, lease)); + await recovery.activate(lease); + await recovery.resume(lease); + + expect(navigate).toHaveBeenCalledWith('/requested'); + }); + + it('不正なremote identityをsessionへ永続化する前に拒否する', async () => { + const { bridge, offline } = setupBridge({ + exchangeImpl: async () => ({ ...identity, userId: 0 }), + }); + const { lease } = createLease(); + + await expect(bridge.onAuthorized!(stateStub, lease)).rejects.toThrow('Offline remote identity userId must be a positive safe integer.'); + expect(offline.prepareRemoteSession).not.toHaveBeenCalled(); + }); +}); diff --git a/projects/kit/offline/src/lib/offline-auth-bridge.ts b/projects/kit/offline/src/lib/offline-auth-bridge.ts new file mode 100644 index 0000000..c208dd9 --- /dev/null +++ b/projects/kit/offline/src/lib/offline-auth-bridge.ts @@ -0,0 +1,169 @@ +import { inject } from '@angular/core'; +import { toObservable } from '@angular/core/rxjs-interop'; +import type { RouterStateSnapshot } from '@angular/router'; +import type { KitAuthAccessLease, KitAuthConfig, KitRemoteAccessRecovery } from '@rdlabo/ionic-angular-kit'; +import type { Observable } from 'rxjs'; +import { distinctUntilChanged, map } from 'rxjs/operators'; +import { OfflineCoordinatorService } from './offline-coordinator.service'; + +/** Remote identity fields required to activate an offline session boundary. */ +export interface OfflineRemoteIdentity { + readonly userId: number; + readonly scopeIds: readonly number[]; + readonly authSubject: string; +} + +/** Phase that distinguishes route authorization from background remote recovery. */ +export type OfflineAuthExchangePhase = 'authorize' | 'recover'; + +/** Context passed to the product credential exchange callback. */ +export interface OfflineAuthExchangeContext { + readonly phase: OfflineAuthExchangePhase; + readonly state?: RouterStateSnapshot; + readonly lease: KitAuthAccessLease; +} + +/** Context supplied after remote transport has resumed for the same authenticated identity. */ +export interface OfflineAuthResumeContext { + readonly phase: OfflineAuthExchangePhase; + readonly state?: RouterStateSnapshot; + readonly lease: KitAuthAccessLease; + readonly identity: TIdentity; +} + +/** Options for {@link createOfflineAuthBridge}. */ +export interface CreateOfflineAuthBridgeOptions { + /** Coordinator override for tests or custom runtimes; otherwise injected from the current context. */ + readonly offline?: OfflineCoordinatorService; + /** + * Exchanges a stored or refreshed credential for a remote identity. + * + * @returns `null` or `false` when the product declines activation without treating it as an error. + */ + readonly exchange: (context: OfflineAuthExchangeContext) => Promise; + /** Provider subject currently known to the product auth layer. */ + readonly currentAuthSubject: () => string | null | undefined; + /** Classifies transport unavailability for guard fallback and recovery retry. */ + readonly isUnavailableError: (error: unknown) => boolean; + /** Remote availability stream; defaults to `offline.networkState !== 'offline'`. */ + readonly availability?: () => Observable; + /** Optional stronger identity check in addition to the required auth-subject match. */ + readonly isIdentityCurrent?: (identity: TIdentity) => boolean; + /** Optional product hook after the kit publishes remote access and transport resumes. */ + readonly onRemoteResumed?: (context: OfflineAuthResumeContext) => Promise; + /** Delay before {@link KitAuthRecoveryService} retries recovery while local access remains active. */ + readonly retryDelayMs?: number; +} + +/** {@link KitAuthConfig} hooks wired for offline-coordinated remote and local access. */ +export type OfflineAuthBridgeConfig = Pick; + +/** + * Builds the offline-aware {@link KitAuthConfig} fragment for guarded route activation and recovery. + * + * @remarks + * The bridge owns `exchange` → {@link OfflineCoordinatorService.prepareRemoteSession} → kit + * `grantRemote` → {@link OfflineCoordinatorService.resumeRemoteSession} ordering by returning + * {@link KitRemoteAccessRecovery}. Product consent, error UI, and DTO mapping stay outside. + * + * @example + * ```ts + * provideKitAuth(() => ({ + * authState: () => auth.state$, + * ...createOfflineAuthBridge({ + * exchange: async (ctx) => { + * const identity = await auth.exchangeCredential(ctx); + * const authSubject = auth.currentSubject(); + * return identity && authSubject ? { ...identity, authSubject } : false; + * }, + * currentAuthSubject: () => auth.currentSubject(), + * isUnavailableError: isOfflineFallbackError, + * }), + * redirects, + * })); + * ``` + */ +export function createOfflineAuthBridge( + options: CreateOfflineAuthBridgeOptions, +): OfflineAuthBridgeConfig { + const offline = options.offline ?? inject(OfflineCoordinatorService); + const { exchange, currentAuthSubject, isUnavailableError, isIdentityCurrent, onRemoteResumed, retryDelayMs } = options; + const defaultAvailability$ = options.availability + ? undefined + : toObservable(offline.networkState).pipe( + map((state) => state !== 'offline'), + distinctUntilChanged(), + ); + const availability = options.availability ?? (() => defaultAvailability$!); + + const identityStillCurrent = (lease: KitAuthAccessLease, identity: TIdentity): boolean => + lease.isCurrent() && currentAuthSubject() === identity.authSubject && (isIdentityCurrent?.(identity) ?? true); + + const resolveRemoteAccess = async ( + phase: OfflineAuthExchangePhase, + state: RouterStateSnapshot | undefined, + lease: KitAuthAccessLease, + ): Promise => { + if (!lease.isCurrent()) return false; + const identity = await exchange({ phase, state, lease }); + if (!lease.isCurrent()) return false; + if (identity === null || identity === false) return false; + assertOfflineRemoteIdentity(identity); + if (!identityStillCurrent(lease, identity)) return false; + + return { + activate: async (activateLease) => { + if (!identityStillCurrent(activateLease, identity)) return false; + const identityLease: KitAuthAccessLease = { + isCurrent: () => identityStillCurrent(activateLease, identity), + }; + const prepared = await offline.prepareRemoteSession(identity.userId, identity.scopeIds, identity.authSubject, identityLease); + return prepared && identityLease.isCurrent(); + }, + resume: async (resumeLease) => { + const resumeStillCurrent = (): boolean => + (resumeLease?.isCurrent() ?? true) && currentAuthSubject() === identity.authSubject && (isIdentityCurrent?.(identity) ?? true); + if (!resumeStillCurrent()) return; + await offline.resumeRemoteSession(); + if (!resumeStillCurrent()) return; + if (onRemoteResumed) { + await onRemoteResumed({ + phase, + state, + lease: resumeLease ?? lease, + identity, + }); + if (!resumeStillCurrent()) return; + } + }, + }; + }; + + return { + isUnavailableError, + onAuthorized: (state, lease) => resolveRemoteAccess('authorize', state, lease), + onUnavailable: async (_state, _error, lease) => { + if (!lease.isCurrent()) return false; + const manifest = await offline.activateOfflineSession(currentAuthSubject(), lease); + if (!lease.isCurrent()) return false; + return manifest !== null; + }, + remoteRecovery: { + retryDelayMs, + availability, + reauthenticate: (lease) => resolveRemoteAccess('recover', undefined, lease), + }, + }; +} + +function assertOfflineRemoteIdentity(identity: OfflineRemoteIdentity): void { + if (!Number.isSafeInteger(identity.userId) || identity.userId <= 0) { + throw new Error('Offline remote identity userId must be a positive safe integer.'); + } + if (!Array.isArray(identity.scopeIds) || identity.scopeIds.some((scopeId) => !Number.isSafeInteger(scopeId) || scopeId < 0)) { + throw new Error('Offline remote identity scopeIds must contain only non-negative safe integers.'); + } + if (typeof identity.authSubject !== 'string' || identity.authSubject.length === 0) { + throw new Error('Offline remote identity authSubject must be a non-empty string.'); + } +} diff --git a/projects/kit/offline/src/public-api.ts b/projects/kit/offline/src/public-api.ts index 1a3ea42..06e76d9 100644 --- a/projects/kit/offline/src/public-api.ts +++ b/projects/kit/offline/src/public-api.ts @@ -4,6 +4,7 @@ export * from './lib/offline-replica-puller'; export * from './lib/offline-replica-pull.service'; export * from './lib/offline-command-executor'; export * from './lib/offline-command-hooks'; +export * from './lib/offline-auth-bridge'; export * from './lib/offline-coordinator.service'; export * from './lib/offline-kit-options'; export * from './lib/offline-network.service';