diff --git a/projects/kit/README.md b/projects/kit/README.md index 75dce5a..3e14729 100644 --- a/projects/kit/README.md +++ b/projects/kit/README.md @@ -4,7 +4,7 @@ A small ergonomic kit for Ionic Angular applications. It provides: - **KitStorageService** — a typed, write-loss-safe wrapper around `@ionic/storage-angular` - **KitOverlayController** — a unified presenter for Ionic Modal, Toast, and Alert -- **Auth guards** — functional `CanActivateFn` guards for a 4-state auth model +- **Auth guards** — functional guards plus shared `none` / `local` / `remote` runtime access - **HTTP interceptor** — a fleet-canonical auth + retry + error-hook interceptor - **KitRealtimeConnection** — foreground/network-aware Hibernation WebSocket reconnect and resync - **KitAuthInputDirective** — sign-in email remember/prefill + iOS autofill workaround for `ion-input` @@ -59,6 +59,8 @@ authentication and the stable `KIT_REALTIME_CLIENT_ID` through WebSocket subprot putting credentials in the URL. Domain event types, authorization, room selection, and REST resync behavior remain in the app. +Offline-capable authenticated clients set `requireRemoteAccess: true` in `realtimeOptions`; sockets then close on +`local` / `none` and reopen only after `KitAuthAccessService` publishes `remote`. --- @@ -227,16 +229,42 @@ This centralizes presentation options, keeps component props and dismiss data ty ### Auth guards + provideKitAuth -Functional `CanActivateFn` guards for a four-state auth model: - -| State | Meaning | -| ------------- | ---------------------------------------------------- | -| `'user'` | Fully authenticated | -| `'confirm'` | Authenticated but email confirmation pending | -| `'required'` | Not authenticated | -| `'anonymous'` | Anonymous login active (can be prompted to register) | - -**Convention:** every redirect path is supplied via `provideKitAuth`; the kit does not hard-code any routes. `authState` and `redirects` are required. The app-specific hooks `onAuthorized` / `onUnauthenticated` are **optional** and default to `true` (allow the authenticated user through) / `false` (fall through to the `whenUnauthorized` redirect), so an app only supplies the ones with real logic. +Functional `CanActivateFn` guards for a five-state auth model: + +| State | Meaning | +| --------------- | -------------------------------------------------------- | +| `'user'` | Fully authenticated | +| `'confirm'` | Authenticated but email confirmation pending | +| `'required'` | Not authenticated | +| `'anonymous'` | Anonymous login active (can be prompted to register) | +| `'unavailable'` | The authentication authority cannot currently be reached | + +**Convention:** every redirect path is supplied via `provideKitAuth`; the kit does not hard-code any routes. +`authState` and `redirects` are required. The app-specific hooks `onAuthorized`, `onUnauthenticated`, and +`onUnavailable` are optional. An authenticated user is allowed by default; unauthenticated and unavailable states +redirect by default. + +`'required'` is an authoritative signed-out result. It must never be converted into offline access. +`'unavailable'` means the authentication authority could not produce a result. Likewise, +`isUnavailableError` must classify transport failures only; HTTP 401/403 are explicit denials and must return +`false`. `onUnavailable` authorizes the route for local-replica use only—it does not create an HTTP or realtime +credential. + +`KitAuthAccessService` is the authoritative capability state for the rest of the application: + +| Access mode | Local replica / outbox | Authenticated HTTP / realtime / sync | +| ----------- | ---------------------- | ------------------------------------ | +| `none` | blocked | blocked | +| `local` | allowed | blocked | +| `remote` | allowed | allowed | + +Remote activation has two ordered phases. `activate()` installs the remotely verified identity without starting +transport. The guard then publishes `remote`, and only then calls `resume()` to start pull, outbox replay, and +realtime work. Returning plain `true` remains supported for applications that do not need phased activation. +When a protected guard starts a new asynchronous decision, any previously published `remote` capability is +immediately suspended to `none`; it is granted again only after the current lease completes successfully. +Once the authority returns `required` or `confirm`, an existing `local` capability is also suspended before any +anonymous-sign-in fallback runs. Only the `unavailable` path may retain or re-grant verified local access. **Setup** @@ -257,10 +285,38 @@ export const appConfig: ApplicationConfig = { whenUnauthorized: '/auth', // kitRequireAuthorizedGuard }, // onAuthorized / onUnauthenticated omitted → defaults (allow / redirect). - // Supply onAuthorized only when 'user' needs extra work (token login, permissions): - // onAuthorized: async () => { await auth.refreshToken(); return true; }, + // Supply onAuthorized only when 'user' needs extra work. A phased result is preferred when + // activating the offline runtime: + // onAuthorized: async () => { + // const session = await auth.exchangeCredential(); + // return { + // activate: (lease) => offline.prepareRemoteSession( + // session.userId, session.groupIds, session.subject, lease, + // ), + // resume: () => offline.resumeRemoteSession(), + // }; + // }, // Supply onUnauthenticated only for a fallback such as anonymous sign-in: // onUnauthenticated: async () => { await auth.signInAnonymously(); return true; }, + // Supply onUnavailable only for a previously verified local replica: + // onUnavailable: async (_state, _error, lease) => + // (await offline.activateOfflineSession(auth.currentSubject(), lease)) !== null, + // isUnavailableError: (error) => isOfflineFallbackError(error), + // Optionally recover automatically after the authority is reachable again: + // 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; + // }, + // }, }; }), ], @@ -341,6 +397,54 @@ Mutations are queued explicitly with `OfflineSyncService.enqueue`, not through H Web storage uses Ionic Storage; iOS and Android use encrypted `@capacitor-community/sqlite`. Importing either the primary entry point or `/offline` does not pull the optional native SQLite plugin into web-only applications. +For cold-start offline route access, `OfflineCoordinatorService.activateOfflineSession()` restores only a manifest +that is bound to a non-null authentication-provider subject. Supplying a currently known subject also rejects a +different user on a shared device. It activates local replica writes and durable outbox enqueue, but remote pull and +command replay remain disabled until online authentication completes the ordered +`prepareRemoteSession(...)` → publish `remote` → `resumeRemoteSession()` transition. `activateSession(...)` remains +available as a backward-compatible one-step API for callers that do not enforce shared access mode. +Explicit sign-out must first call `KitAuthAccessService.clear()` and then await `clearActiveSession()`. The first +step immediately invalidates every in-flight auth lease; the second serializes cleanup after local persistence +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(), + }; + }, + onUnavailable: async (_state, _error, lease) => + (await offline.activateOfflineSession(auth.currentSubject(), lease)) !== null, + 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, + }; +}); +``` + +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 +returned. + The offline interceptor observes real transport responses to update API reachability. For matched `GET` requests only, a transport failure with `status=0` may return a local replica response tagged `X-Offline-Response: local`. `POST` and other write methods always go to transport unchanged; outbox replay @@ -541,6 +645,9 @@ export const appConfig: ApplicationConfig = { const auth = inject(AuthService); const reload = inject(KitReloadAlertController); return { + // Required for the new offline auth boundary. Kept opt-in so existing applications retain + // their current interceptor behavior until they wire KitAuthAccessService. + enforceAuthAccessMode: true, getAuthHeaders: async (req) => ({ Authorization: `Bearer ${await auth.getToken()}`, }), @@ -561,13 +668,20 @@ export const appConfig: ApplicationConfig = { }; ``` +For an offline replica, use +`withInterceptors([offlineInterceptor, kitAuthInterceptor])` in that order. Authentication/bootstrap endpoints that +must run before `remote` is granted must be explicitly covered by `bypass`; do not globally relax +`enforceAuthAccessMode`. + **Error dispatch** (after retries, in `catchError`): -1. `offlineFallback` non-null → return fallback observable (no further hooks called) -2. `401` → `onUnauthorized` · `403` → `onForbidden` -3. `0` (connected) → `onNetworkError` · `429` → `onRateLimited(retryAfter?)` · `502/503/504` → `onServerBusy(status, retryAfter?)` -4. `400/422/500` with `error.message` → `onServerError` -5. anything else (`404`, …) → not handled here; the caller decides +1. With `enforceAuthAccessMode`, `401` / `403` → revoke access, notify the matching hook, and reject + without consulting `offlineFallback` +2. Otherwise, `offlineFallback` non-null → return fallback observable (no further hooks called) +3. `401` → `onUnauthorized` · `403` → `onForbidden` +4. `0` (connected) → `onNetworkError` · `429` → `onRateLimited(retryAfter?)` · `502/503/504` → `onServerBusy(status, retryAfter?)` +5. `400/422/500` with `error.message` → `onServerError` +6. anything else (`404`, …) → not handled here; the caller decides Plus: a `getAuthHeaders` rejection → `onAuthError(request, error)` (the request is never sent). diff --git a/projects/kit/offline/src/lib/offline-command-executor.ts b/projects/kit/offline/src/lib/offline-command-executor.ts index 2683617..631c1dc 100644 --- a/projects/kit/offline/src/lib/offline-command-executor.ts +++ b/projects/kit/offline/src/lib/offline-command-executor.ts @@ -38,6 +38,9 @@ export interface OfflineSyncSession { /** Product adapter that exposes the currently authenticated synchronization session. */ export interface OfflineSyncContext { + /** Session allowed to read/write the local replica and append durable outbox commands. */ + getLocalSession?(): Promise; + /** Remotely authenticated session allowed to pull and replay commands. */ getSession(): Promise; } diff --git a/projects/kit/offline/src/lib/offline-coordinator.service.spec.ts b/projects/kit/offline/src/lib/offline-coordinator.service.spec.ts new file mode 100644 index 0000000..4076eb8 --- /dev/null +++ b/projects/kit/offline/src/lib/offline-coordinator.service.spec.ts @@ -0,0 +1,151 @@ +import { signal } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { describe, expect, it, vi } from 'vitest'; +import { OfflineCoordinatorService } from './offline-coordinator.service'; +import { OfflineNetworkService } from './offline-network.service'; +import { OFFLINE_REPOSITORY } from './offline-repository'; +import { OfflineSessionService, type OfflineSessionManifest } from './offline-session.service'; +import { OfflineSyncService } from './offline-sync.service'; + +describe('OfflineCoordinatorService', () => { + afterEach(() => TestBed.resetTestingModule()); + + function setup(manifest: OfflineSessionManifest | null = null) { + const order: string[] = []; + const sessionState: { userId: number | null } = { userId: null }; + const repository = { + initialize: vi.fn(async () => undefined), + }; + const network = { + state: signal('connected'), + initialize: vi.fn(async () => undefined), + }; + const session = { + initialize: vi.fn(async () => undefined), + activateSession: vi.fn(async (userId: number, _scopeIds: readonly number[], _authSubject: string | null, lease: { isCurrent(): boolean }) => { + order.push('activate-remote'); + if (!lease.isCurrent()) return false; + sessionState.userId = userId; + return true; + }), + suspendRemoteSession: vi.fn(async () => void order.push('suspend-remote')), + activateOfflineSession: vi.fn(async () => { + order.push('activate-local'); + return manifest; + }), + clearActiveSession: vi.fn(async () => { + order.push('clear'); + sessionState.userId = null; + }), + }; + const sync = { + syncState: signal('idle'), + pendingCount: signal(0), + conflicts: signal([]), + initialize: vi.fn(async () => undefined), + resetSession: vi.fn(async () => void order.push('reset')), + refreshSession: vi.fn(async () => void order.push('resume-remote')), + refreshLocalSession: vi.fn(async () => void order.push('refresh-local')), + discardAllPending: vi.fn(async () => undefined), + flush: vi.fn(async () => undefined), + }; + TestBed.configureTestingModule({ + providers: [ + OfflineCoordinatorService, + { provide: OFFLINE_REPOSITORY, useValue: repository }, + { provide: OfflineNetworkService, useValue: network }, + { provide: OfflineSessionService, useValue: session }, + { provide: OfflineSyncService, useValue: sync }, + ], + }); + return { coordinator: TestBed.inject(OfflineCoordinatorService), order, session, sessionState, sync }; + } + + it('restores local visibility without starting remote synchronization', async () => { + const manifest = { userId: 1, scopeIds: [2], authSubject: 'subject', updatedAt: 1 }; + const { coordinator, order, sync } = setup(manifest); + + await expect(coordinator.activateOfflineSession('subject')).resolves.toEqual(manifest); + + expect(order).toEqual(['reset', 'activate-local', 'refresh-local']); + expect(sync.refreshSession).not.toHaveBeenCalled(); + }); + + it('does not expose local state when no verified manifest can be restored', async () => { + const { coordinator, order, sync } = setup(); + + await expect(coordinator.activateOfflineSession()).resolves.toBeNull(); + + expect(order).toEqual(['reset', 'activate-local']); + expect(sync.refreshLocalSession).not.toHaveBeenCalled(); + }); + + it('separates remote identity activation from transport resume', async () => { + const { coordinator, order } = setup(); + + await coordinator.prepareRemoteSession(1, [2], 'subject'); + expect(order).toEqual(['reset', 'suspend-remote', 'activate-remote']); + + await coordinator.resumeRemoteSession(); + expect(order).toEqual(['reset', 'suspend-remote', 'activate-remote', 'resume-remote']); + }); + + it('serializes logout after an in-flight activation so the old identity cannot reappear', async () => { + const { coordinator, session, sessionState } = setup(); + let releaseActivation: (() => void) | undefined; + let activationStarted: (() => void) | undefined; + const started = new Promise((resolve) => { + activationStarted = resolve; + }); + const gate = new Promise((resolve) => { + releaseActivation = resolve; + }); + session.activateSession.mockImplementationOnce( + async (userId: number, _scopeIds: readonly number[], _subject: string | null, lease: { isCurrent(): boolean }) => { + activationStarted?.(); + await gate; + if (!lease.isCurrent()) return false; + sessionState.userId = userId; + return true; + }, + ); + + const activation = coordinator.prepareRemoteSession(1, [2], 'old-subject'); + await started; + const logout = coordinator.clearActiveSession(); + releaseActivation?.(); + await Promise.all([activation, logout]); + + expect(sessionState.userId).toBeNull(); + }); + + it('keeps a newer identity when an older activation completes late', async () => { + const { coordinator, session, sessionState } = setup(); + let releaseOld: (() => void) | undefined; + let oldStarted: (() => void) | undefined; + const started = new Promise((resolve) => { + oldStarted = resolve; + }); + const gate = new Promise((resolve) => { + releaseOld = resolve; + }); + session.activateSession.mockImplementationOnce( + async (userId: number, _scopeIds: readonly number[], _subject: string | null, lease: { isCurrent(): boolean }) => { + oldStarted?.(); + await gate; + if (!lease.isCurrent()) return false; + sessionState.userId = userId; + return true; + }, + ); + + const oldActivation = coordinator.prepareRemoteSession(1, [2], 'old-subject'); + await started; + const newActivation = coordinator.prepareRemoteSession(9, [10], 'new-subject'); + releaseOld?.(); + + await expect(oldActivation).resolves.toBe(false); + await expect(newActivation).resolves.toBe(true); + expect(sessionState.userId).toBe(9); + }); +}); diff --git a/projects/kit/offline/src/lib/offline-coordinator.service.ts b/projects/kit/offline/src/lib/offline-coordinator.service.ts index 8e75946..19cd574 100644 --- a/projects/kit/offline/src/lib/offline-coordinator.service.ts +++ b/projects/kit/offline/src/lib/offline-coordinator.service.ts @@ -2,6 +2,7 @@ import { inject, Injectable } from '@angular/core'; import { OfflineNetworkService } from './offline-network.service'; import { OFFLINE_REPOSITORY } from './offline-repository'; import { OfflineSessionService } from './offline-session.service'; +import type { OfflineSessionManifest, OfflineSessionTransitionLease } from './offline-session.service'; import { OfflineSyncService } from './offline-sync.service'; /** User choice when logout encounters unconfirmed local mutations. */ @@ -14,6 +15,8 @@ export class OfflineCoordinatorService { readonly #network = inject(OfflineNetworkService); readonly #sync = inject(OfflineSyncService); readonly #session = inject(OfflineSessionService); + #transitionRevision = 0; + #transitionTail: Promise = Promise.resolve(); readonly networkState = this.#network.state; readonly syncState = this.#sync.syncState; @@ -27,14 +30,56 @@ export class OfflineCoordinatorService { } async activateSession(userId: number, scopeIds: readonly number[], authSubject: string | null): Promise { - await this.#sync.resetSession(); - await this.#session.activateSession(userId, scopeIds, authSubject); + if (!(await this.prepareRemoteSession(userId, scopeIds, authSubject))) return; + await this.resumeRemoteSession(); + } + + /** Installs a remotely verified identity without starting pull or outbox replay. */ + prepareRemoteSession( + userId: number, + scopeIds: readonly number[], + authSubject: string | null, + authLease?: OfflineSessionTransitionLease, + ): Promise { + const revision = ++this.#transitionRevision; + const lease = this.#lease(revision, authLease); + return this.#enqueueTransition(async () => { + await this.#sync.resetSession(); + await this.#session.suspendRemoteSession(); + if (!lease.isCurrent()) return false; + return this.#session.activateSession(userId, scopeIds, authSubject, lease); + }); + } + + /** Starts pull and outbox replay after the caller has published remote access. */ + async resumeRemoteSession(): Promise { await this.#sync.refreshSession(); } - async clearActiveSession(): Promise { - await this.#sync.resetSession(); - await this.#session.clearActiveSession(); + /** + * Activates a restored identity for local replica/outbox use without enabling transport sync. + */ + activateOfflineSession( + authSubject?: string | null, + authLease?: OfflineSessionTransitionLease, + ): Promise { + const revision = ++this.#transitionRevision; + const lease = this.#lease(revision, authLease); + return this.#enqueueTransition(async () => { + await this.#sync.resetSession(); + if (!lease.isCurrent()) return null; + const manifest = await this.#session.activateOfflineSession(authSubject, lease); + if (manifest && lease.isCurrent()) await this.#sync.refreshLocalSession(); + return lease.isCurrent() ? manifest : null; + }); + } + + clearActiveSession(): Promise { + ++this.#transitionRevision; + return this.#enqueueTransition(async () => { + await this.#sync.resetSession(); + await this.#session.clearActiveSession(); + }); } async prepareLogout(action: OfflineLogoutAction): Promise { @@ -50,4 +95,17 @@ export class OfflineCoordinatorService { flush(): Promise { return this.#sync.flush(); } + + #lease(revision: number, authLease?: OfflineSessionTransitionLease): OfflineSessionTransitionLease { + return { isCurrent: () => revision === this.#transitionRevision && (authLease?.isCurrent() ?? true) }; + } + + #enqueueTransition(operation: () => Promise): Promise { + const transition = this.#transitionTail.then(operation, operation); + this.#transitionTail = transition.then( + () => undefined, + () => undefined, + ); + return transition; + } } 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 8531af5..f75d020 100644 --- a/projects/kit/offline/src/lib/offline-session.service.spec.ts +++ b/projects/kit/offline/src/lib/offline-session.service.spec.ts @@ -11,12 +11,7 @@ describe('OfflineSessionService shared-device boundary', () => { beforeEach(() => { lastUserId = 10; - manifests = new Map([ - [ - 10, - { userId: 10, scopeIds: [1], authSubject: 'uid-A', updatedAt: 1 }, - ], - ]); + manifests = new Map([[10, { userId: 10, scopeIds: [1], authSubject: 'uid-A', updatedAt: 1 }]]); clearUser = vi.fn(async (userId: number) => { manifests.delete(userId); if (lastUserId === userId) lastUserId = null; @@ -42,15 +37,54 @@ describe('OfflineSessionService shared-device boundary', () => { it('起動時に旧manifestを復元しても認証後activateまではsync contextへ公開しない', async () => { await service.initialize(); + await expect(service.getLocalSession()).resolves.toBeNull(); await expect(service.getSession()).resolves.toBeNull(); }); + it('認証基盤へ到達不能ならsubject付きmanifestをlocal accessへ復元する', async () => { + await expect(service.getOfflineAccessManifest()).resolves.toEqual({ + userId: 10, + scopeIds: [1], + authSubject: 'uid-A', + updatedAt: 1, + }); + await expect(service.getSession()).resolves.toBeNull(); + }); + + it('offline sessionはlocal/outbox contextだけを有効にしてremote syncを許可しない', async () => { + await expect(service.activateOfflineSession('uid-A')).resolves.toMatchObject({ userId: 10 }); + await expect(service.getLocalSession()).resolves.toEqual({ + userId: 10, + scopes: [{ userId: 10, groupId: 1 }], + }); + await expect(service.getSession()).resolves.toBeNull(); + }); + + it('既知のsubjectがmanifestと違う場合はlocal accessを拒否する', async () => { + await expect(service.getOfflineAccessManifest('uid-B')).resolves.toBeNull(); + await expect(service.getOfflineAccessManifest(null)).resolves.toBeNull(); + }); + + it('legacy null subjectのmanifestはlocal accessへ復元しない', async () => { + manifests.set(10, { userId: 10, scopeIds: [1], authSubject: null, updatedAt: 1 }); + await expect(service.getOfflineAccessManifest()).resolves.toBeNull(); + }); + + it('明示logoutでclearしたmanifestはlocal accessへ復元しない', async () => { + await service.clearActiveSession(); + await expect(service.getOfflineAccessManifest()).resolves.toBeNull(); + }); + it('AからBへ認証主体が変わるとA全scopeを削除してからBを有効化する', async () => { await service.initialize(); await service.activateSession(20, [2], 'uid-B'); expect(clearUser).toHaveBeenCalledWith(10); expect(lastUserId).toBe(20); await expect(service.getSession()).resolves.toEqual({ userId: 20, scopes: [{ userId: 20, groupId: 2 }] }); + await expect(service.getLocalSession()).resolves.toEqual({ + userId: 20, + scopes: [{ userId: 20, groupId: 2 }], + }); expect(service.activeManifest()).toMatchObject({ userId: 20, authSubject: '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 c938596..8eac9dc 100644 --- a/projects/kit/offline/src/lib/offline-session.service.ts +++ b/projects/kit/offline/src/lib/offline-session.service.ts @@ -11,13 +11,19 @@ export interface OfflineSessionManifest { updatedAt: number; } +/** Structural lease used to reject stale asynchronous session commits. */ +export interface OfflineSessionTransitionLease { + isCurrent(): boolean; +} + /** Owns activation and cleanup of the authenticated local-replica boundary. */ @Injectable({ providedIn: 'root' }) export class OfflineSessionService { readonly #repository = inject(OFFLINE_REPOSITORY); readonly #activeManifest = signal(null); #initialized = false; - #activatedThisRun = false; + #localAccessThisRun = false; + #remoteActivatedThisRun = false; readonly activeManifest = this.#activeManifest.asReadonly(); @@ -32,18 +38,31 @@ export class OfflineSessionService { this.#initialized = true; } - async activateSession(userId: number, scopeIds: readonly number[], authSubject: string | null): Promise { + async activateSession(userId: number, scopeIds: readonly number[], authSubject: string | null): Promise; + async activateSession( + userId: number, + scopeIds: readonly number[], + authSubject: string | null, + lease: OfflineSessionTransitionLease, + ): Promise; + async activateSession( + userId: number, + scopeIds: readonly number[], + authSubject: string | null, + lease?: OfflineSessionTransitionLease, + ): 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 previousUserId = await this.#repository.getLastUserId(); - let previous = - previousUserId === userId - ? ((await this.#repository.getSessionManifest(userId)) ?? null) - : null; + if (lease && !lease.isCurrent()) return false; + let previous = previousUserId === userId ? ((await this.#repository.getSessionManifest(userId)) ?? null) : null; + if (lease && !lease.isCurrent()) return false; // A changed provider subject is a different person even when the product reuses its numeric id. // This deliberately also clears legacy null -> known subject and known subject -> null transitions. if (previousUserId !== null && (previousUserId !== userId || previous?.authSubject !== authSubject)) { await this.#repository.clearUser(previousUserId); + if (lease && !lease.isCurrent()) return false; previous = null; } const active = new Set(normalizedScopeIds); @@ -52,6 +71,7 @@ export class OfflineSessionService { .filter((groupId) => !active.has(groupId)) .map((groupId) => this.#repository.clearGroup({ userId, groupId })), ); + if (lease && !lease.isCurrent()) return false; const manifest: OfflineSessionManifest = { userId, @@ -60,9 +80,13 @@ export class OfflineSessionService { updatedAt: Date.now(), }; await this.#repository.setLastUserId(userId); + if (lease && !lease.isCurrent()) return false; await this.#repository.putSessionManifest(userId, manifest); + if (lease && !lease.isCurrent()) return false; this.#activeManifest.set(manifest); - this.#activatedThisRun = true; + this.#localAccessThisRun = true; + this.#remoteActivatedThisRun = true; + return lease ? true : undefined; } async clearActiveSession(): Promise { @@ -70,12 +94,70 @@ export class OfflineSessionService { const userId = await this.#repository.getLastUserId(); if (userId !== null) await this.#repository.clearUser(userId); this.#activeManifest.set(null); - this.#activatedThisRun = false; + this.#localAccessThisRun = false; + this.#remoteActivatedThisRun = false; + } + + /** Disable remote pull/replay eligibility while retaining the verified local manifest. */ + async suspendRemoteSession(): Promise { + await this.initialize(); + this.#remoteActivatedThisRun = false; } + /** + * Returns the persisted identity boundary for local-only route access. + * + * @remarks + * This does not activate the sync context. Call it only after the authentication authority has + * been classified as unavailable, never after explicit sign-out or HTTP 401/403. Legacy + * manifests without an authentication-provider subject are rejected. + * + * @param authSubject - A currently known provider subject. When supplied, it must match the + * persisted subject. + */ + async getOfflineAccessManifest(authSubject?: string | null): Promise { + await this.initialize(); + const manifest = this.#activeManifest(); + if (!manifest?.authSubject || (authSubject !== undefined && manifest.authSubject !== authSubject)) { + return null; + } + return { ...manifest, scopeIds: [...manifest.scopeIds] }; + } + + /** + * Activates a previously verified manifest for local replica and outbox access only. + * + * @remarks + * This never enables pull or command replay. A later successful remote authentication must call + * {@link activateSession} before synchronization can use transport. + * + * @param authSubject - A currently known provider subject. When supplied, it must match the + * persisted subject. + */ + async activateOfflineSession( + authSubject?: string | null, + lease?: OfflineSessionTransitionLease, + ): Promise { + const manifest = await this.getOfflineAccessManifest(authSubject); + if (lease && !lease.isCurrent()) return null; + this.#localAccessThisRun = manifest !== null; + this.#remoteActivatedThisRun = false; + return manifest; + } + + /** Returns the session allowed to use the local replica and append outbox commands. */ + async getLocalSession(): Promise<{ userId: number; scopes: OfflineScope[] } | null> { + await this.initialize(); + return this.#localAccessThisRun ? this.#sessionFromManifest() : null; + } + + /** Returns the remotely authenticated session eligible for pull and command replay. */ async getSession(): Promise<{ userId: number; scopes: OfflineScope[] } | null> { await this.initialize(); - if (!this.#activatedThisRun) return null; + return this.#remoteActivatedThisRun ? this.#sessionFromManifest() : null; + } + + #sessionFromManifest(): { userId: number; scopes: OfflineScope[] } | null { const manifest = this.#activeManifest(); return manifest ? { userId: manifest.userId, scopes: manifest.scopeIds.map((groupId) => ({ userId: manifest.userId, groupId })) } diff --git a/projects/kit/offline/src/lib/offline-sync.service.spec.ts b/projects/kit/offline/src/lib/offline-sync.service.spec.ts index 7849b4b..f728df4 100644 --- a/projects/kit/offline/src/lib/offline-sync.service.spec.ts +++ b/projects/kit/offline/src/lib/offline-sync.service.spec.ts @@ -42,6 +42,7 @@ describe('OfflineSyncService', () => { let rows: OfflineReplicaRow[]; let connected: ReturnType>; let session: { userId: number; scopes: OfflineScope[] } | null; + let localSession: { userId: number; scopes: OfflineScope[] } | null | undefined; let beforePutCommand: ((command: OfflineCommand) => Promise) | null; let pull: ReturnType Promise>>; let handleError: ReturnType void>>; @@ -54,6 +55,7 @@ describe('OfflineSyncService', () => { rows = []; connected = signal(false); session = { userId: 1, scopes: [{ userId: 1, groupId: 10 }] }; + localSession = undefined; beforePutCommand = null; pull = vi.fn(async () => undefined); handleError = vi.fn(); @@ -132,7 +134,10 @@ describe('OfflineSyncService', () => { { provide: ErrorHandler, useValue: { handleError } }, { provide: OFFLINE_SYNC_CONTEXT, - useValue: { getSession: vi.fn(async () => session) }, + useValue: { + getLocalSession: vi.fn(async () => (localSession === undefined ? session : localSession)), + getSession: vi.fn(async () => session), + }, }, { provide: OFFLINE_COMMAND_EXECUTOR, @@ -143,6 +148,30 @@ describe('OfflineSyncService', () => { service = TestBed.inject(OfflineSyncService); }); + it('local sessionはoutboxへenqueueできるがremote session確立までは送信しない', async () => { + localSession = { userId: 1, scopes: [{ userId: 1, groupId: 10 }] }; + session = null; + await service.refreshLocalSession(); + + await service.enqueue( + { + groupId: 10, + aggregateType: 'documents', + aggregateLocalId: 'offline-local', + operation: 'documents.create', + payload: { title: 'offline' }, + optimisticValue: { id: 0, title: 'offline' }, + }, + { flush: false }, + ); + connected.set(true); + await service.flush(); + + expect(execute).not.toHaveBeenCalled(); + expect(commands).toHaveLength(1); + expect(service.pendingCount()).toBe(1); + }); + it('同じaggregateの操作を作成順に送り、成功後だけoutboxから除く', async () => { await service.enqueue( { diff --git a/projects/kit/offline/src/lib/offline-sync.service.ts b/projects/kit/offline/src/lib/offline-sync.service.ts index 34f8ef1..fa8ddaa 100644 --- a/projects/kit/offline/src/lib/offline-sync.service.ts +++ b/projects/kit/offline/src/lib/offline-sync.service.ts @@ -1,5 +1,10 @@ import { computed, effect, ErrorHandler, inject, Injectable, signal } from '@angular/core'; -import { OFFLINE_COMMAND_EXECUTOR, OFFLINE_SYNC_CONTEXT, type OfflineCommandResult } from './offline-command-executor'; +import { + OFFLINE_COMMAND_EXECUTOR, + OFFLINE_SYNC_CONTEXT, + type OfflineCommandResult, + type OfflineSyncSession, +} from './offline-command-executor'; import { OFFLINE_COMMAND_HOOKS } from './offline-command-hooks'; import { OFFLINE_KIT_OPTIONS } from './offline-kit-options'; import { OfflineNetworkService } from './offline-network.service'; @@ -106,6 +111,14 @@ export class OfflineSyncService { if (this.#network.connected()) this.#flushInBackground(); } + /** Restores local outbox visibility without enabling pull or replay transport. */ + async refreshLocalSession(): Promise { + await this.initialize(); + await this.#discoverLocalScopes(); + await this.#restoreInterruptedCommands(); + await this.#refreshState(); + } + async resetSession(): Promise { this.#invalidateFlush(); await this.#waitForSendingTransitions(); @@ -127,7 +140,7 @@ export class OfflineSyncService { async #enqueue(request: EnqueueOfflineCommand, options: { flush?: boolean }): Promise { await this.initialize(); - const session = await this.#context.getSession(); + const session = await this.#getLocalSession(); if (!session) throw new Error('Cannot enqueue an offline command without an authenticated user'); const userId = session.userId; this.#setActiveUser(userId); @@ -159,9 +172,7 @@ export class OfflineSyncService { if (initialServerId !== null) { const mapped = await this.#repository.getReplicaRowByServerId(scope, entityType, initialServerId); if (mapped !== null && mapped.localId !== aggregateLocalId) { - throw new Error( - `Offline replica serverId ${String(initialServerId)} is already mapped to localId ${mapped.localId}.`, - ); + throw new Error(`Offline replica serverId ${String(initialServerId)} is already mapped to localId ${mapped.localId}.`); } } const optimisticRow: OfflineReplicaRow = { @@ -452,6 +463,18 @@ export class OfflineSyncService { async #discoverScopes(generation = this.#generation): Promise { const session = await this.#context.getSession(); if (!this.#isCurrent(generation)) return false; + if (!session) { + return false; + } + this.#setActiveUser(session.userId); + this.#knownScopes.clear(); + for (const scope of session.scopes) this.#knownScopes.set(this.#scopeKey(scope), scope); + return true; + } + + async #discoverLocalScopes(generation = this.#generation): Promise { + const session = await this.#getLocalSession(); + if (!this.#isCurrent(generation)) return false; if (!session) { this.#activeUserId = null; this.#knownScopes.clear(); @@ -463,6 +486,10 @@ export class OfflineSyncService { return true; } + #getLocalSession(): Promise { + return this.#context.getLocalSession?.() ?? this.#context.getSession(); + } + #setActiveUser(userId: number): void { if (this.#activeUserId === userId) return; this.#knownScopes.clear(); diff --git a/projects/kit/offline/src/lib/offline.interceptor.spec.ts b/projects/kit/offline/src/lib/offline.interceptor.spec.ts index 29c0a67..2d1343c 100644 --- a/projects/kit/offline/src/lib/offline.interceptor.spec.ts +++ b/projects/kit/offline/src/lib/offline.interceptor.spec.ts @@ -51,10 +51,10 @@ describe('offlineInterceptor', () => { expect(markApiFailure).toHaveBeenCalledOnce(); }); - it('403/500はlocal replicaで隠さない', async () => { + it('401/403/500はlocal replicaで隠さない', async () => { const readLocal = vi.fn(); resolve.mockReturnValue({ kind: 'read', readLocal }); - for (const status of [403, 500]) { + for (const status of [401, 403, 500]) { const error = new HttpErrorResponse({ status }); await expect(firstValueFrom(run(new HttpRequest('GET', '/bootstrap'), () => throwError(() => error)))).rejects.toBe(error); } diff --git a/projects/kit/src/lib/auth/auth-access.service.spec.ts b/projects/kit/src/lib/auth/auth-access.service.spec.ts new file mode 100644 index 0000000..3e37882 --- /dev/null +++ b/projects/kit/src/lib/auth/auth-access.service.spec.ts @@ -0,0 +1,209 @@ +import { ErrorHandler } from '@angular/core'; +import { TestBed } from '@angular/core/testing'; +import { Subject } from 'rxjs'; +import { describe, expect, it, vi } from 'vitest'; +import { + KIT_AUTH_RECOVERY_CONFIG, + KitAuthAccessService, + KitAuthRecoveryService, + type KitAuthRecoveryConfig, + type KitRemoteAccessRecovery, +} from './auth-access.service'; + +describe('KitAuthRecoveryService', () => { + function setup(config: KitAuthRecoveryConfig) { + const errorHandler = { handleError: vi.fn() }; + TestBed.configureTestingModule({ + providers: [ + KitAuthAccessService, + KitAuthRecoveryService, + { provide: KIT_AUTH_RECOVERY_CONFIG, useValue: config }, + { provide: ErrorHandler, useValue: errorHandler }, + ], + }); + return { + access: TestBed.inject(KitAuthAccessService), + recovery: TestBed.inject(KitAuthRecoveryService), + errorHandler, + }; + } + + afterEach(() => TestBed.resetTestingModule()); + + it('recovers in reauthenticate → activate → remote publish → resume order', async () => { + const availability = new Subject(); + const order: string[] = []; + const reauthenticate = vi.fn(async () => { + order.push('reauthenticate'); + return { + activate: async () => { + order.push('activate'); + return true; + }, + resume: async () => { + order.push(`resume:${TestBed.inject(KitAuthAccessService).mode}`); + }, + }; + }); + const setupResult = setup({ + remoteRecovery: { availability: () => availability, reauthenticate }, + isUnavailableError: (error) => (error as { status?: number })?.status === 0, + }); + const access = setupResult.access; + access.grantLocal(); + setupResult.recovery.initialize(); + + availability.next(true); + await setupResult.recovery.recover(); + + expect(order).toEqual(['reauthenticate', 'activate', 'resume:remote']); + expect(access.mode).toBe('remote'); + expect(reauthenticate).toHaveBeenCalledOnce(); + }); + + it('coalesces concurrent recovery attempts into one flight', async () => { + let resolveRecovery: ((value: false) => void) | undefined; + const reauthenticate = vi.fn( + () => + new Promise((resolve) => { + resolveRecovery = resolve; + }), + ); + const { access, recovery } = setup({ + remoteRecovery: { availability: () => new Subject(), reauthenticate }, + }); + access.grantLocal(); + + const first = recovery.recover(); + const second = recovery.recover(); + resolveRecovery?.(false); + await Promise.all([first, second]); + + expect(reauthenticate).toHaveBeenCalledOnce(); + expect(access.mode).toBe('none'); + }); + + it('keeps local mode on transport failure and clears it on explicit denial', async () => { + const transportError = { status: 0 }; + const denied = { status: 401 }; + const reauthenticate = vi.fn().mockRejectedValueOnce(transportError).mockRejectedValueOnce(denied); + const { access, recovery } = setup({ + remoteRecovery: { availability: () => new Subject(), reauthenticate }, + isUnavailableError: (error) => error === transportError, + }); + access.grantLocal(); + + await recovery.recover(); + expect(access.mode).toBe('local'); + await recovery.recover(); + expect(access.mode).toBe('none'); + }); + + it('does not restore remote access after a newer access transition invalidates recovery', async () => { + let resolveReauthentication: ((value: KitRemoteAccessRecovery) => void) | undefined; + const activate = vi.fn(async () => true); + const resume = vi.fn(async () => undefined); + const { access, recovery } = setup({ + remoteRecovery: { + availability: () => new Subject(), + reauthenticate: () => + new Promise((resolve) => { + resolveReauthentication = resolve; + }), + }, + }); + access.grantLocal(); + + const pending = recovery.recover(); + access.clear(); + resolveReauthentication?.({ activate, resume }); + await pending; + + expect(access.mode).toBe('none'); + expect(activate).not.toHaveBeenCalled(); + expect(resume).not.toHaveBeenCalled(); + }); + + it('does not commit an identity when logout completes while activation is waiting', async () => { + let releaseActivation: (() => void) | undefined; + let activationStarted: (() => void) | undefined; + const started = new Promise((resolve) => { + activationStarted = resolve; + }); + const gate = new Promise((resolve) => { + releaseActivation = resolve; + }); + let manifest: string | null = null; + let localSession: string | null = null; + let remoteSession: string | null = null; + const { access, recovery } = setup({ + remoteRecovery: { + availability: () => new Subject(), + reauthenticate: async () => ({ + activate: async (lease) => { + activationStarted?.(); + await gate; + if (!lease.isCurrent()) return false; + manifest = localSession = remoteSession = 'old-user'; + return true; + }, + resume: async () => undefined, + }), + }, + }); + access.grantLocal(); + + const pending = recovery.recover(); + await started; + access.clear(); + manifest = localSession = remoteSession = null; + releaseActivation?.(); + await pending; + + expect(access.mode).toBe('none'); + expect(manifest).toBeNull(); + expect(localSession).toBeNull(); + expect(remoteSession).toBeNull(); + }); + + it('preserves a newer identity when an older activation is released afterward', async () => { + let releaseOld: (() => void) | undefined; + let oldStarted: (() => void) | undefined; + const started = new Promise((resolve) => { + oldStarted = resolve; + }); + const gate = new Promise((resolve) => { + releaseOld = resolve; + }); + let manifest: string | null = null; + const oldResume = vi.fn(async () => undefined); + const { access, recovery } = setup({ + remoteRecovery: { + availability: () => new Subject(), + reauthenticate: async () => ({ + activate: async (lease) => { + oldStarted?.(); + await gate; + if (!lease.isCurrent()) return false; + manifest = 'old-user'; + return true; + }, + resume: oldResume, + }), + }, + }); + access.grantLocal(); + + const oldRecovery = recovery.recover(); + await started; + access.beginTransition(); + manifest = 'new-user'; + access.grantRemote(); + releaseOld?.(); + await oldRecovery; + + expect(manifest).toBe('new-user'); + expect(access.mode).toBe('remote'); + expect(oldResume).not.toHaveBeenCalled(); + }); +}); diff --git a/projects/kit/src/lib/auth/auth-access.service.ts b/projects/kit/src/lib/auth/auth-access.service.ts new file mode 100644 index 0000000..9fc7289 --- /dev/null +++ b/projects/kit/src/lib/auth/auth-access.service.ts @@ -0,0 +1,176 @@ +import { ErrorHandler, inject, Injectable, InjectionToken } from '@angular/core'; +import type { Observable, Subscription } from 'rxjs'; +import { BehaviorSubject } from 'rxjs'; + +/** Access level currently granted to the application runtime. */ +export type KitAuthAccessMode = 'none' | 'local' | 'remote'; + +/** Generation lease that becomes stale as soon as a newer access transition starts. */ +export interface KitAuthAccessLease { + /** Whether work owned by this transition may still publish or persist access. */ + isCurrent(): boolean; +} + +/** + * Result of remote reauthentication, split into ordered activation and resume phases. + * + * @remarks + * `activate` installs the remotely authenticated identity without starting transport. The kit then + * publishes `remote` access and invokes `resume`, which may start pull, outbox replay, and realtime + * work. + */ +export interface KitRemoteAccessRecovery { + /** + * Install the remotely verified identity, checking the lease again immediately before commit. + * + * @returns `false` when a newer logout or identity transition superseded this activation. + */ + activate(lease: KitAuthAccessLease): Promise; + resume(): Promise; +} + +/** Recovery-specific authentication configuration consumed by {@link KitAuthRecoveryService}. */ +export interface KitAuthRecoveryConfig { + /** Classifies transport unavailability; HTTP 401/403 are always denied before this callback. */ + isUnavailableError?(error: unknown): boolean; + /** Optional online-recovery lifecycle used after local-only route access. */ + remoteRecovery?: { + availability(): Observable; + reauthenticate(lease: KitAuthAccessLease): Promise; + }; +} + +/** Internal alias of the application auth config used without a module cycle. */ +export const KIT_AUTH_RECOVERY_CONFIG = new InjectionToken('@rdlabo/ionic-angular-kit:auth-recovery'); + +/** Shared runtime access state consumed by guards, UI, HTTP, and realtime connections. */ +@Injectable({ providedIn: 'root' }) +export class KitAuthAccessService { + readonly #mode = new BehaviorSubject('none'); + #revision = 0; + + /** Emits the current access mode and every later transition. */ + readonly mode$: Observable = this.#mode.asObservable(); + + /** Current synchronous access mode. */ + get mode(): KitAuthAccessMode { + return this.#mode.value; + } + + /** + * Monotonic runtime revision used to invalidate an in-flight access transition. + * + * @internal + */ + get revision(): number { + return this.#revision; + } + + /** + * Start a transition and invalidate every older asynchronous access decision. + * + * @param options - Set `suspendRemote` when the new decision must revoke published remote + * capabilities immediately while keeping this new lease valid. + */ + beginTransition(options: { suspendRemote?: boolean } = {}): KitAuthAccessLease { + this.#revision += 1; + const revision = this.#revision; + if (options.suspendRemote && this.#mode.value === 'remote') { + this.#mode.next('none'); + } + return { isCurrent: () => this.#revision === revision }; + } + + /** Revoke published capabilities without invalidating the transition that owns `lease`. */ + suspend(lease: KitAuthAccessLease): boolean { + if (!lease.isCurrent()) return false; + if (this.#mode.value !== 'none') this.#mode.next('none'); + return true; + } + + /** Publish a verified local-replica-only session. */ + grantLocal(): void { + this.#publish('local'); + } + + /** Publish a remotely authenticated session. */ + grantRemote(): void { + this.#publish('remote'); + } + + /** Revoke both local and remote access. */ + clear(): void { + this.#publish('none'); + } + + #publish(mode: KitAuthAccessMode): void { + this.#revision += 1; + this.#mode.next(mode); + } +} + +/** Coordinates single-flight recovery from local-only mode to remote access. */ +@Injectable({ providedIn: 'root' }) +export class KitAuthRecoveryService { + readonly #access = inject(KitAuthAccessService); + readonly #config = inject(KIT_AUTH_RECOVERY_CONFIG); + readonly #errorHandler = inject(ErrorHandler); + #subscription: Subscription | null = null; + #recovery: Promise | null = null; + + /** Subscribe to the configured remote-availability stream once. */ + initialize(): void { + const recovery = this.#config.remoteRecovery; + if (!recovery || this.#subscription) return; + this.#subscription = recovery.availability().subscribe({ + next: (available) => { + if (available && this.#access.mode === 'local') void this.recover(); + }, + error: (error) => this.#errorHandler.handleError(error), + }); + } + + /** Run one ordered remote recovery attempt, coalescing concurrent triggers. */ + recover(): Promise { + if (this.#recovery) return this.#recovery; + const promise = this.#runRecovery().finally(() => { + if (this.#recovery === promise) this.#recovery = null; + }); + this.#recovery = promise; + return promise; + } + + async #runRecovery(): Promise { + const recovery = this.#config.remoteRecovery; + if (!recovery || this.#access.mode !== 'local') return; + const lease = this.#access.beginTransition(); + let expectedRevision = this.#access.revision; + const isCurrent = (): boolean => this.#access.revision === expectedRevision; + try { + const result = await recovery.reauthenticate(lease); + if (!isCurrent() || this.#access.mode !== 'local') return; + if (result === false) { + this.#access.clear(); + return; + } + if (!(await result.activate(lease)) || !isCurrent() || this.#access.mode !== 'local') return; + this.#access.grantRemote(); + expectedRevision = this.#access.revision; + await result.resume(); + } catch (error) { + if (!isCurrent()) return; + if (isExplicitAuthDenial(error)) { + this.#access.clear(); + } else if (!this.#config.isUnavailableError?.(error)) { + this.#errorHandler.handleError(error); + } + } + } +} + +/** Returns true for authoritative authentication denials that must never use local fallback. */ +export function isExplicitAuthDenial(error: unknown): boolean { + if (typeof error !== 'object' || error === null) return false; + const status = (error as { status?: unknown }).status; + return status === 401 || status === 403; +} diff --git a/projects/kit/src/lib/auth/auth-guards.spec.ts b/projects/kit/src/lib/auth/auth-guards.spec.ts index 87f2013..b526beb 100644 --- a/projects/kit/src/lib/auth/auth-guards.spec.ts +++ b/projects/kit/src/lib/auth/auth-guards.spec.ts @@ -4,16 +4,19 @@ import type { ActivatedRouteSnapshot, RouterStateSnapshot, UrlTree } from '@angu import { Router } from '@angular/router'; import { NavController } from '@ionic/angular/standalone'; import type { Observable } from 'rxjs'; -import { of } from 'rxjs'; +import { of, Subject } from 'rxjs'; +import { throwError } from 'rxjs'; import { firstValueFrom } from 'rxjs'; import { + type KitAuthGuardState, type KitAuthState, provideKitAuth, kitRequiredUnauthorizedGuard, kitRequireConfirmingGuard, kitRequireAuthorizedGuard, } from './auth-guards'; +import { KitAuthAccessService, type KitAuthAccessLease, type KitRemoteAccessRecovery } from './auth-access.service'; // --------------------------------------------------------------------------- // Helpers @@ -43,13 +46,23 @@ function mockFn(): T { } function setup( - state: KitAuthState, + state: KitAuthGuardState, { - onAuthorized = vi.fn().mockResolvedValue(true) as unknown as (s: RouterStateSnapshot) => Promise, - onUnauthenticated = vi.fn().mockResolvedValue(false) as unknown as (s: RouterStateSnapshot) => Promise, + onAuthorized = vi.fn().mockResolvedValue(true) as unknown as ( + s: RouterStateSnapshot, + ) => Promise, + onUnauthenticated = vi.fn().mockResolvedValue(false) as unknown as ( + s: RouterStateSnapshot, + ) => Promise, + onUnavailable = vi.fn().mockResolvedValue(false) as unknown as (s: RouterStateSnapshot, error?: unknown) => Promise, + isUnavailableError = vi.fn().mockReturnValue(false) as unknown as (error: unknown) => boolean, + authState = () => of(state), }: { - onAuthorized?: (s: RouterStateSnapshot) => Promise; - onUnauthenticated?: (s: RouterStateSnapshot) => Promise; + onAuthorized?: (s: RouterStateSnapshot) => Promise; + onUnauthenticated?: (s: RouterStateSnapshot) => Promise; + onUnavailable?: (s: RouterStateSnapshot, error?: unknown) => Promise; + isUnavailableError?: (error: unknown) => boolean; + authState?: () => Observable; } = {}, ) { const navigate = vi.fn().mockResolvedValue(true); @@ -59,9 +72,11 @@ function setup( providers: [ provideZonelessChangeDetection(), provideKitAuth(() => ({ - authState: () => of(state), + authState, onAuthorized, onUnauthenticated, + onUnavailable, + isUnavailableError, redirects: REDIRECTS, })), { provide: Router, useValue: { navigate } }, @@ -69,7 +84,7 @@ function setup( ], }); - return { navigate, setDirection, onAuthorized, onUnauthenticated }; + return { navigate, setDirection, onAuthorized, onUnauthenticated, onUnavailable, isUnavailableError }; } // --------------------------------------------------------------------------- @@ -96,8 +111,10 @@ describe('kitRequiredUnauthorizedGuard', () => { it("'required' → returns true", async () => { setup('required'); + TestBed.inject(KitAuthAccessService).grantLocal(); const result = await runGuard(TestBed.runInInjectionContext(() => kitRequiredUnauthorizedGuard(routeStub, stateStub))); expect(result).toBe(true); + expect(TestBed.inject(KitAuthAccessService).mode).toBe('none'); }); it("'anonymous' → returns true", async () => { @@ -105,6 +122,21 @@ describe('kitRequiredUnauthorizedGuard', () => { const result = await runGuard(TestBed.runInInjectionContext(() => kitRequiredUnauthorizedGuard(routeStub, stateStub))); expect(result).toBe(true); }); + + it('does not let a stale auth-page guard clear or redirect a newer identity', async () => { + const authState = new Subject(); + const { navigate } = setup('required', { authState: () => authState }); + const pending = runGuard(TestBed.runInInjectionContext(() => kitRequiredUnauthorizedGuard(routeStub, stateStub))); + const access = TestBed.inject(KitAuthAccessService); + access.beginTransition(); + access.grantRemote(); + + authState.next('required'); + + await expect(pending).resolves.toBe(false); + expect(access.mode).toBe('remote'); + expect(navigate).not.toHaveBeenCalled(); + }); }); // --------------------------------------------------------------------------- @@ -115,8 +147,10 @@ describe('kitRequireConfirmingGuard', () => { it("'confirm' → returns true", async () => { setup('confirm'); + TestBed.inject(KitAuthAccessService).grantRemote(); const result = await runGuard(TestBed.runInInjectionContext(() => kitRequireConfirmingGuard(routeStub, stateStub))); expect(result).toBe(true); + expect(TestBed.inject(KitAuthAccessService).mode).toBe('none'); }); it("'anonymous' → navigates whenAuthorized and returns false", async () => { @@ -142,6 +176,21 @@ describe('kitRequireConfirmingGuard', () => { expect(setDirection).toHaveBeenCalledWith('root'); expect(navigate).toHaveBeenCalledWith([REDIRECTS.whenNotConfirming]); }); + + it('does not let a stale confirming guard clear or redirect a newer identity', async () => { + const authState = new Subject(); + const { navigate } = setup('confirm', { authState: () => authState }); + const pending = runGuard(TestBed.runInInjectionContext(() => kitRequireConfirmingGuard(routeStub, stateStub))); + const access = TestBed.inject(KitAuthAccessService); + access.beginTransition(); + access.grantRemote(); + + authState.next('confirm'); + + await expect(pending).resolves.toBe(false); + expect(access.mode).toBe('remote'); + expect(navigate).not.toHaveBeenCalled(); + }); }); // --------------------------------------------------------------------------- @@ -155,7 +204,8 @@ describe('kitRequireAuthorizedGuard', () => { setup('user', { onAuthorized }); const result = await runGuard(TestBed.runInInjectionContext(() => kitRequireAuthorizedGuard(routeStub, stateStub))); expect(result).toBe(true); - expect(onAuthorized).toHaveBeenCalledWith(stateStub); + expect(onAuthorized).toHaveBeenCalledWith(stateStub, expect.objectContaining({ isCurrent: expect.any(Function) })); + expect(TestBed.inject(KitAuthAccessService).mode).toBe('remote'); }); it("'user' → propagates UrlTree from onAuthorized", async () => { @@ -166,6 +216,38 @@ describe('kitRequireAuthorizedGuard', () => { expect(result).toBe(urlTree); }); + it("'user' → publishes remote access between phased activation and transport resume", async () => { + const order: string[] = []; + const onAuthorized = vi.fn(async () => ({ + activate: async () => { + order.push('activate'); + return true; + }, + resume: async () => void order.push(`resume:${TestBed.inject(KitAuthAccessService).mode}`), + })); + setup('user', { onAuthorized }); + + const result = await runGuard(TestBed.runInInjectionContext(() => kitRequireAuthorizedGuard(routeStub, stateStub))); + + expect(result).toBe(true); + expect(order).toEqual(['activate', 'resume:remote']); + expect(TestBed.inject(KitAuthAccessService).mode).toBe('remote'); + }); + + it("'user' → keeps verified remote access when only phased resume loses transport", async () => { + const networkError = { status: 0 }; + const onAuthorized = vi.fn(async () => ({ + activate: async () => true, + resume: async () => Promise.reject(networkError), + })); + setup('user', { onAuthorized, isUnavailableError: (error) => error === networkError }); + + await expect( + runGuard(TestBed.runInInjectionContext(() => kitRequireAuthorizedGuard(routeStub, stateStub))), + ).resolves.toBe(true); + expect(TestBed.inject(KitAuthAccessService).mode).toBe('remote'); + }); + it("'anonymous' → returns true without calling any hook", async () => { const onAuthorized = vi.fn() as unknown as (s: RouterStateSnapshot) => Promise; const onUnauthenticated = vi.fn() as unknown as (s: RouterStateSnapshot) => Promise; @@ -207,6 +289,298 @@ describe('kitRequireAuthorizedGuard', () => { expect(result).toBe(false); expect(navigate).toHaveBeenCalledWith([REDIRECTS.whenUnauthorized]); }); + + it("'unavailable' invokes only onUnavailable and allows local route access", async () => { + const onUnavailable = vi.fn().mockResolvedValue(true) as unknown as ( + s: RouterStateSnapshot, + error?: unknown, + ) => Promise; + const { onUnauthenticated } = setup('unavailable', { onUnavailable }); + const result = await runGuard(TestBed.runInInjectionContext(() => kitRequireAuthorizedGuard(routeStub, stateStub))); + expect(result).toBe(true); + expect(onUnavailable).toHaveBeenCalledWith( + stateStub, + undefined, + expect.objectContaining({ isCurrent: expect.any(Function) }), + ); + expect(onUnauthenticated).not.toHaveBeenCalled(); + expect(TestBed.inject(KitAuthAccessService).mode).toBe('local'); + }); + + it("'unavailable' + rejected local restore redirects without granting access", async () => { + const onUnavailable = vi.fn().mockResolvedValue(false); + const { navigate } = setup('unavailable', { onUnavailable }); + + await expect( + runGuard(TestBed.runInInjectionContext(() => kitRequireAuthorizedGuard(routeStub, stateStub))), + ).resolves.toBe(false); + expect(navigate).toHaveBeenCalledWith([REDIRECTS.whenUnauthorized]); + expect(TestBed.inject(KitAuthAccessService).mode).toBe('none'); + }); + + it("'required' never invokes the unavailable fallback", async () => { + const onUnavailable = vi.fn().mockResolvedValue(true) as unknown as ( + s: RouterStateSnapshot, + error?: unknown, + ) => Promise; + const { navigate } = setup('required', { onUnavailable }); + const result = await runGuard(TestBed.runInInjectionContext(() => kitRequireAuthorizedGuard(routeStub, stateStub))); + expect(result).toBe(false); + expect(onUnavailable).not.toHaveBeenCalled(); + expect(navigate).toHaveBeenCalledWith([REDIRECTS.whenUnauthorized]); + }); + + it('classified onAuthorized transport failure invokes onUnavailable', async () => { + const networkError = { status: 0 }; + const onAuthorized = vi.fn().mockRejectedValue(networkError) as unknown as (s: RouterStateSnapshot) => Promise; + const onUnavailable = vi.fn().mockResolvedValue(true) as unknown as ( + s: RouterStateSnapshot, + error?: unknown, + ) => Promise; + setup('user', { + onAuthorized, + onUnavailable, + isUnavailableError: (error) => error === networkError, + }); + const result = await runGuard(TestBed.runInInjectionContext(() => kitRequireAuthorizedGuard(routeStub, stateStub))); + expect(result).toBe(true); + expect(onUnavailable).toHaveBeenCalledWith( + stateStub, + networkError, + expect.objectContaining({ isCurrent: expect.any(Function) }), + ); + }); + + it('unclassified onAuthorized error propagates without local fallback', async () => { + const unauthorized = { status: 401 }; + const onAuthorized = vi.fn().mockRejectedValue(unauthorized) as unknown as (s: RouterStateSnapshot) => Promise; + const onUnavailable = vi.fn().mockResolvedValue(true) as unknown as ( + s: RouterStateSnapshot, + error?: unknown, + ) => Promise; + setup('user', { onAuthorized, onUnavailable, isUnavailableError: () => false }); + await expect(runGuard(TestBed.runInInjectionContext(() => kitRequireAuthorizedGuard(routeStub, stateStub)))).rejects.toBe(unauthorized); + expect(onUnavailable).not.toHaveBeenCalled(); + }); + + it.each([401, 403])('HTTP %i from onAuthorized never uses local fallback even with an overly broad classifier', async (status) => { + const denial = { status }; + const onAuthorized = vi.fn().mockRejectedValue(denial) as unknown as (s: RouterStateSnapshot) => Promise; + const onUnavailable = vi.fn().mockResolvedValue(true) as unknown as ( + s: RouterStateSnapshot, + error?: unknown, + ) => Promise; + setup('user', { onAuthorized, onUnavailable, isUnavailableError: () => true }); + + await expect(runGuard(TestBed.runInInjectionContext(() => kitRequireAuthorizedGuard(routeStub, stateStub)))).rejects.toBe(denial); + expect(onUnavailable).not.toHaveBeenCalled(); + expect(TestBed.inject(KitAuthAccessService).mode).toBe('none'); + }); + + it('does not grant remote access when logout supersedes a pending onAuthorized hook', async () => { + let resolveAuthorized: ((value: true) => void) | undefined; + const onAuthorized = vi.fn( + () => + new Promise((resolve) => { + resolveAuthorized = resolve; + }), + ); + setup('user', { onAuthorized }); + + const pending = runGuard(TestBed.runInInjectionContext(() => kitRequireAuthorizedGuard(routeStub, stateStub))); + const access = TestBed.inject(KitAuthAccessService); + access.clear(); + resolveAuthorized?.(true); + + await expect(pending).resolves.toBe(false); + expect(access.mode).toBe('none'); + }); + + it('does not grant remote access when logout supersedes phased activation', async () => { + let releaseActivation: (() => void) | undefined; + let activationStarted: (() => void) | undefined; + const started = new Promise((resolve) => { + activationStarted = resolve; + }); + const gate = new Promise((resolve) => { + releaseActivation = resolve; + }); + const resume = vi.fn(async () => undefined); + const onAuthorized = vi.fn(async () => ({ + activate: async (lease: KitAuthAccessLease) => { + activationStarted?.(); + await gate; + return lease.isCurrent(); + }, + resume, + })); + setup('user', { onAuthorized }); + + const pending = runGuard(TestBed.runInInjectionContext(() => kitRequireAuthorizedGuard(routeStub, stateStub))); + await started; + const access = TestBed.inject(KitAuthAccessService); + access.clear(); + releaseActivation?.(); + + await expect(pending).resolves.toBe(false); + expect(access.mode).toBe('none'); + expect(resume).not.toHaveBeenCalled(); + }); + + it('does not grant local access when logout supersedes a pending unavailable fallback', async () => { + let resolveUnavailable: ((value: true) => void) | undefined; + const onUnavailable = vi.fn( + () => + new Promise((resolve) => { + resolveUnavailable = resolve; + }), + ); + setup('unavailable', { onUnavailable }); + + const pending = runGuard(TestBed.runInInjectionContext(() => kitRequireAuthorizedGuard(routeStub, stateStub))); + const access = TestBed.inject(KitAuthAccessService); + access.clear(); + resolveUnavailable?.(true); + + await expect(pending).resolves.toBe(false); + expect(access.mode).toBe('none'); + }); + + it('suspends existing remote capabilities while unauthenticated fallback is pending', async () => { + let resolveUnauthenticated: ((value: false) => void) | undefined; + const onUnauthenticated = vi.fn( + () => + new Promise((resolve) => { + resolveUnauthenticated = resolve; + }), + ); + setup('required', { onUnauthenticated }); + const access = TestBed.inject(KitAuthAccessService); + access.grantRemote(); + + const pending = runGuard(TestBed.runInInjectionContext(() => kitRequireAuthorizedGuard(routeStub, stateStub))); + expect(access.mode).toBe('none'); + resolveUnauthenticated?.(false); + + await expect(pending).resolves.toBe(false); + expect(access.mode).toBe('none'); + }); + + it('suspends existing local capabilities while authoritative sign-out fallback is pending', async () => { + let resolveUnauthenticated: ((value: false) => void) | undefined; + const onUnauthenticated = vi.fn( + () => + new Promise((resolve) => { + resolveUnauthenticated = resolve; + }), + ); + setup('required', { onUnauthenticated }); + const access = TestBed.inject(KitAuthAccessService); + access.grantLocal(); + + const pending = runGuard(TestBed.runInInjectionContext(() => kitRequireAuthorizedGuard(routeStub, stateStub))); + expect(access.mode).toBe('none'); + resolveUnauthenticated?.(false); + + await expect(pending).resolves.toBe(false); + expect(access.mode).toBe('none'); + }); + + it('suspends existing remote capabilities until unavailable fallback verifies local access', async () => { + let resolveUnavailable: ((value: true) => void) | undefined; + const onUnavailable = vi.fn( + () => + new Promise((resolve) => { + resolveUnavailable = resolve; + }), + ); + setup('unavailable', { onUnavailable }); + const access = TestBed.inject(KitAuthAccessService); + access.grantRemote(); + + const pending = runGuard(TestBed.runInInjectionContext(() => kitRequireAuthorizedGuard(routeStub, stateStub))); + expect(access.mode).toBe('none'); + resolveUnavailable?.(true); + + await expect(pending).resolves.toBe(true); + expect(access.mode).toBe('local'); + }); +}); + +describe('kitRequireAuthorizedGuard — auth state source errors', () => { + afterEach(() => TestBed.resetTestingModule()); + + it('classified transport error invokes onUnavailable', async () => { + const networkError = { status: 0 }; + const onUnavailable = vi.fn().mockResolvedValue(true); + TestBed.configureTestingModule({ + providers: [ + provideZonelessChangeDetection(), + provideKitAuth(() => ({ + authState: () => throwError(() => networkError), + onUnavailable, + isUnavailableError: (error) => error === networkError, + redirects: REDIRECTS, + })), + { provide: Router, useValue: { navigate: vi.fn() } }, + { provide: NavController, useValue: { setDirection: vi.fn() } }, + ], + }); + + const result = await runGuard(TestBed.runInInjectionContext(() => kitRequireAuthorizedGuard(routeStub, stateStub))); + expect(result).toBe(true); + expect(onUnavailable).toHaveBeenCalledWith( + stateStub, + networkError, + expect.objectContaining({ isCurrent: expect.any(Function) }), + ); + }); + + it('does not classify an error from onUnavailable a second time', async () => { + const networkError = { status: 0 }; + const localStoreError = { status: 0, source: 'local-store' }; + const onUnavailable = vi.fn().mockRejectedValue(localStoreError); + TestBed.configureTestingModule({ + providers: [ + provideZonelessChangeDetection(), + provideKitAuth(() => ({ + authState: () => throwError(() => networkError), + onUnavailable, + isUnavailableError: (error) => (error as { status?: number })?.status === 0, + redirects: REDIRECTS, + })), + { provide: Router, useValue: { navigate: vi.fn() } }, + { provide: NavController, useValue: { setDirection: vi.fn() } }, + ], + }); + + await expect(runGuard(TestBed.runInInjectionContext(() => kitRequireAuthorizedGuard(routeStub, stateStub)))).rejects.toBe( + localStoreError, + ); + expect(onUnavailable).toHaveBeenCalledOnce(); + }); + + it.each([401, 403])('HTTP %i from authState never uses local fallback even with an overly broad classifier', async (status) => { + const denial = { status }; + const onUnavailable = vi.fn().mockResolvedValue(true); + TestBed.configureTestingModule({ + providers: [ + provideZonelessChangeDetection(), + provideKitAuth(() => ({ + authState: () => throwError(() => denial), + onUnavailable, + isUnavailableError: () => true, + redirects: REDIRECTS, + })), + { provide: Router, useValue: { navigate: vi.fn() } }, + { provide: NavController, useValue: { setDirection: vi.fn() } }, + ], + }); + + await expect(runGuard(TestBed.runInInjectionContext(() => kitRequireAuthorizedGuard(routeStub, stateStub)))).rejects.toBe(denial); + expect(onUnavailable).not.toHaveBeenCalled(); + expect(TestBed.inject(KitAuthAccessService).mode).toBe('none'); + }); }); // A config may omit the optional hooks; the guard then applies the built-in defaults diff --git a/projects/kit/src/lib/auth/auth-guards.ts b/projects/kit/src/lib/auth/auth-guards.ts index e1ed9b3..a94e7a5 100644 --- a/projects/kit/src/lib/auth/auth-guards.ts +++ b/projects/kit/src/lib/auth/auth-guards.ts @@ -1,10 +1,20 @@ import type { EnvironmentProviders } from '@angular/core'; -import { inject, InjectionToken, makeEnvironmentProviders } from '@angular/core'; +import { inject, InjectionToken, makeEnvironmentProviders, provideAppInitializer } from '@angular/core'; import type { CanActivateFn, RouterStateSnapshot, UrlTree } from '@angular/router'; import { Router } from '@angular/router'; import { NavController } from '@ionic/angular/standalone'; import type { Observable } from 'rxjs'; -import { map, mergeMap } from 'rxjs/operators'; +import { of, throwError } from 'rxjs'; +import { catchError, map, mergeMap } from 'rxjs/operators'; +import { + isExplicitAuthDenial, + type KitAuthAccessLease, + type KitAuthRecoveryConfig, + type KitRemoteAccessRecovery, + KitAuthAccessService, + KIT_AUTH_RECOVERY_CONFIG, + KitAuthRecoveryService, +} from './auth-access.service'; /** * Discriminated set of authentication states the guards react to. @@ -20,6 +30,16 @@ import { map, mergeMap } from 'rxjs/operators'; */ export type KitAuthState = 'user' | 'confirm' | 'required' | 'anonymous'; +/** + * Authentication states accepted by route guards. + * + * @remarks + * `unavailable` means the authentication authority cannot currently be reached; this is distinct + * from an explicit signed-out result. {@link KitAuthState} remains the original four-state union + * so existing exhaustive consumers remain source-compatible. + */ +export type KitAuthGuardState = KitAuthState | 'unavailable'; + /** * Redirect targets (route paths) used by the guards when access is denied. * @@ -42,11 +62,11 @@ export interface KitAuthRedirects { * Configuration consumed by the authentication guards, injected through {@link provideKitAuth}. * * @remarks - * `authState` and `redirects` are required. The `onAuthorized` / `onUnauthenticated` hooks are - * optional and default to allowing the authenticated user through (`true`) and falling through to - * the default redirect (`false`) respectively, so an app only supplies the ones with real logic. + * `authState` and `redirects` are required. The `onAuthorized`, `onUnauthenticated`, and + * `onUnavailable` hooks are optional. Without them the guard allows an authenticated user and + * redirects unauthenticated or unavailable states, so an app only supplies hooks with real logic. */ -export interface KitAuthConfig { +export interface KitAuthConfig extends KitAuthRecoveryConfig { /** * Source of the current authentication state. * @@ -55,7 +75,7 @@ export interface KitAuthConfig { * * @returns A stream of {@link KitAuthState} values. */ - authState(): Observable; + authState(): Observable; /** * Application-specific work that runs in {@link kitRequireAuthorizedGuard} after the state is confirmed to be `user`. * @@ -64,9 +84,11 @@ export interface KitAuthConfig { * or restoring a previously requested redirect. Optional; defaults to `true` (allow activation). * * @param state - The router state snapshot of the route being activated. - * @returns `true` to allow activation, or a `UrlTree` to perform a custom redirect. + * @param lease - Transition lease that product persistence must verify immediately before commit. + * @returns `true` to allow activation, a `UrlTree` to perform a custom redirect, or a phased + * remote activation that installs the session before transport work resumes. */ - onAuthorized?(state: RouterStateSnapshot): Promise; + onAuthorized?(state: RouterStateSnapshot, lease: KitAuthAccessLease): Promise; /** * Fallback that runs in {@link kitRequireAuthorizedGuard} when the state is `required` (not authenticated). * @@ -75,9 +97,28 @@ export interface KitAuthConfig { * (fall through to the default `whenUnauthorized` redirect). * * @param state - The router state snapshot of the route being activated. - * @returns `true` to allow activation, a `UrlTree` for a custom redirect, or `false` to use the default redirect. + * @param lease - Transition lease that product persistence must verify immediately before commit. + * @returns `true` to allow activation, a `UrlTree` for a custom redirect, a phased remote + * activation, or `false` to use the default redirect. */ - onUnauthenticated?(state: RouterStateSnapshot): Promise; + onUnauthenticated?(state: RouterStateSnapshot, lease: KitAuthAccessLease): Promise; + /** + * Fallback that runs only when authentication is unavailable, never for an explicit + * unauthenticated result. + * + * @remarks + * This hook may authorize read/write access to a previously verified local replica. It must not + * provide credentials to HTTP or realtime transports. Explicit sign-out and HTTP 401/403 must + * remain unauthorized. + * + * @param state - The router state snapshot of the route being activated. + * @param error - The classified transport error, or `undefined` when `authState` emitted + * `unavailable`. + * @param lease - Transition lease that local-session activation must verify before commit. + * @returns `true` to allow local route activation, a `UrlTree` for a custom redirect, or `false` + * to use the default redirect. + */ + onUnavailable?(state: RouterStateSnapshot, error: unknown | undefined, lease: KitAuthAccessLease): Promise; /** Redirect targets used by the guards. */ redirects: KitAuthRedirects; } @@ -115,15 +156,19 @@ export const KIT_AUTH_CONFIG = new InjectionToken('@rdlabo/ionic- * ``` */ export const provideKitAuth = (configFactory: () => KitAuthConfig): EnvironmentProviders => - makeEnvironmentProviders([{ provide: KIT_AUTH_CONFIG, useFactory: configFactory }]); + makeEnvironmentProviders([ + { provide: KIT_AUTH_CONFIG, useFactory: configFactory }, + { provide: KIT_AUTH_RECOVERY_CONFIG, useExisting: KIT_AUTH_CONFIG }, + provideAppInitializer(() => inject(KitAuthRecoveryService).initialize()), + ]); /** * Guard that requires the user to be unauthenticated (for example sign-in or sign-up pages). * * @remarks - * Allows the `required` and `anonymous` states (an anonymous user is permitted to proceed to a - * registration page). An authenticated user (`user`) is sent to `whenAuthorized`, and a user - * awaiting confirmation (`confirm`) is sent to `whenConfirming`. + * Allows the `required`, `anonymous`, and `unavailable` states (an anonymous user is permitted to + * proceed to a registration page). An authenticated user (`user`) is sent to `whenAuthorized`, and + * a user awaiting confirmation (`confirm`) is sent to `whenConfirming`. * * @returns A stream emitting `true` to allow activation, or `false` after triggering a redirect. * @@ -136,9 +181,17 @@ export const kitRequiredUnauthorizedGuard: CanActivateFn = () => { const { authState, redirects } = inject(KIT_AUTH_CONFIG); const router = inject(Router); const navCtrl = inject(NavController); + const access = inject(KitAuthAccessService); + const lease = access.beginTransition({ suspendRemote: true }); return authState().pipe( + catchError((error) => { + if (lease.isCurrent() && isExplicitAuthDenial(error)) access.clear(); + return throwError(() => error); + }), map((data) => { + if (!lease.isCurrent()) return false; + if (data !== 'unavailable') access.clear(); if (data === 'user') { navCtrl.setDirection('root'); router.navigate([redirects.whenAuthorized]); @@ -147,7 +200,7 @@ export const kitRequiredUnauthorizedGuard: CanActivateFn = () => { router.navigate([redirects.whenConfirming]); return false; } - // 'required' | 'anonymous' + // 'required' | 'anonymous' | 'unavailable' return true; }), ); @@ -171,9 +224,17 @@ export const kitRequireConfirmingGuard: CanActivateFn = () => { const { authState, redirects } = inject(KIT_AUTH_CONFIG); const router = inject(Router); const navCtrl = inject(NavController); + const access = inject(KitAuthAccessService); + const lease = access.beginTransition({ suspendRemote: true }); return authState().pipe( + catchError((error) => { + if (lease.isCurrent() && isExplicitAuthDenial(error)) access.clear(); + return throwError(() => error); + }), map((data) => { + if (!lease.isCurrent()) return false; + if (data !== 'unavailable') access.clear(); if (data === 'confirm') { return true; } @@ -190,6 +251,8 @@ export const kitRequireConfirmingGuard: CanActivateFn = () => { * @remarks * - `user` — runs {@link KitAuthConfig.onAuthorized} (token login, permission checks, and so on). * - `anonymous` — allowed as-is, for applications that permit anonymous browsing. + * - `unavailable` — runs {@link KitAuthConfig.onUnavailable}; this is the only state intended for + * restored local-replica access. * - `required` / `confirm` — runs {@link KitAuthConfig.onUnauthenticated}; if it resolves to `false`, * the user is redirected to `whenUnauthorized`. * @@ -203,27 +266,127 @@ export const kitRequireConfirmingGuard: CanActivateFn = () => { * ``` */ export const kitRequireAuthorizedGuard: CanActivateFn = (_route, state) => { - const { authState, onAuthorized, onUnauthenticated, redirects } = inject(KIT_AUTH_CONFIG); + const { authState, onAuthorized, onUnauthenticated, onUnavailable, isUnavailableError, redirects } = inject(KIT_AUTH_CONFIG); const router = inject(Router); const navCtrl = inject(NavController); + const access = inject(KitAuthAccessService); + const lease = access.beginTransition({ suspendRemote: true }); + const redirectUnauthorized = (): false => { + if (!lease.isCurrent()) return false; + access.clear(); + navCtrl.setDirection('root'); + router.navigate([redirects.whenUnauthorized]); + return false; + }; + const resolveUnavailable = async (error?: unknown): Promise => { + try { + const fallback = onUnavailable ? await onUnavailable(state, error, lease) : false; + if (!lease.isCurrent()) return false; + if (fallback === true) { + access.grantLocal(); + return true; + } + if (fallback === false) return redirectUnauthorized(); + access.clear(); + return fallback; + } catch (fallbackError) { + if (!lease.isCurrent()) return false; + access.clear(); + throw fallbackError; + } + }; + const resolveRemote = async ( + result: boolean | UrlTree | KitRemoteAccessRecovery, + ): Promise => { + if (!lease.isCurrent()) return false; + if (isRemoteAccessActivation(result)) { + if (!(await result.activate(lease)) || !lease.isCurrent()) return false; + access.grantRemote(); + const remoteRevision = access.revision; + try { + await result.resume(); + } catch (error) { + if (access.revision !== remoteRevision) return false; + if (isExplicitAuthDenial(error)) { + access.clear(); + throw error; + } + if (!isUnavailableError?.(error)) throw error; + } + return access.revision === remoteRevision; + } + if (result === true) access.grantRemote(); + else access.clear(); + return result; + }; + + interface AuthEmission { + authState: KitAuthGuardState; + error?: unknown; + } return authState().pipe( - mergeMap(async (data) => { + map((authState): AuthEmission => ({ authState })), + catchError((error): Observable => { + if (!lease.isCurrent()) return of({ authState: 'required' }); + if (isExplicitAuthDenial(error)) { + access.clear(); + return throwError(() => error); + } + return isUnavailableError?.(error) ? of({ authState: 'unavailable', error }) : throwError(() => error); + }), + mergeMap(async ({ authState: data, error: authStateError }) => { + if (!lease.isCurrent()) return false; if (data === 'user') { // 既定は「許可」。tokenLogin / 権限確認等が必要なアプリだけ onAuthorized を渡す。 - return onAuthorized ? onAuthorized(state) : true; + if (!onAuthorized) { + if (!lease.isCurrent()) return false; + access.grantRemote(); + return true; + } + try { + const result = await onAuthorized(state, lease); + if (!lease.isCurrent()) return false; + return await resolveRemote(result); + } catch (error) { + if (!lease.isCurrent()) return false; + if (isExplicitAuthDenial(error)) { + access.clear(); + throw error; + } + if (!isUnavailableError?.(error)) throw error; + return resolveUnavailable(error); + } } if (data === 'anonymous') { + if (!lease.isCurrent()) return false; + access.grantRemote(); return true; } + if (data === 'unavailable') { + return resolveUnavailable(authStateError); + } + // `required` / `confirm` are authoritative denials. Revoke a previously verified local + // capability before an anonymous-sign-in fallback (if any) is allowed to await. + if (!access.suspend(lease)) return false; // 既定は false(whenUnauthorized へ)。匿名ログイン等のフォールバックが要るアプリだけ渡す。 - const fallback = onUnauthenticated ? await onUnauthenticated(state) : false; + const fallback = onUnauthenticated ? await onUnauthenticated(state, lease) : false; + if (!lease.isCurrent()) return false; if (fallback !== false) { - return fallback; + return resolveRemote(fallback); } - navCtrl.setDirection('root'); - router.navigate([redirects.whenUnauthorized]); - return false; + return redirectUnauthorized(); }), ); }; + +function isRemoteAccessActivation(value: boolean | UrlTree | KitRemoteAccessRecovery): value is KitRemoteAccessRecovery { + return ( + typeof value === 'object' && + value !== null && + 'activate' in value && + typeof value.activate === 'function' && + 'resume' in value && + typeof value.resume === 'function' + ); +} diff --git a/projects/kit/src/lib/http/kit-http.interceptor.spec.ts b/projects/kit/src/lib/http/kit-http.interceptor.spec.ts index 584f96d..76996f4 100644 --- a/projects/kit/src/lib/http/kit-http.interceptor.spec.ts +++ b/projects/kit/src/lib/http/kit-http.interceptor.spec.ts @@ -6,6 +6,7 @@ import { of, throwError } from 'rxjs'; import { firstValueFrom } from 'rxjs'; import { kitAuthInterceptor, provideKitHttp, type KitHttpConfig } from './kit-http.interceptor'; +import { KitAuthAccessService } from '../auth/auth-access.service'; // --------------------------------------------------------------------------- // Mock @capacitor/network so Network.getStatus() never hits native code. @@ -133,6 +134,89 @@ describe('kitAuthInterceptor', () => { }); }); + describe('shared auth access mode', () => { + it('serves local fallback without generating headers or using transport', async () => { + const fallbackResponse = new HttpResponse({ status: 200, body: 'local' }); + const config = makeConfig({ + enforceAuthAccessMode: true, + offlineFallback: vi.fn().mockReturnValue(of(fallbackResponse)), + }); + setupInterceptor(config); + TestBed.inject(KitAuthAccessService).grantLocal(); + const next = vi.fn(); + + const result = await firstValueFrom(runInterceptor(baseReq, next)); + + expect(result).toBe(fallbackResponse); + expect(config.getAuthHeaders).not.toHaveBeenCalled(); + expect(next).not.toHaveBeenCalled(); + }); + + it('none mode rejects without exposing local fallback or transport', async () => { + const config = makeConfig({ enforceAuthAccessMode: true }); + setupInterceptor(config); + const next = vi.fn(); + + await expect(firstValueFrom(runInterceptor(baseReq, next))).rejects.toMatchObject({ status: 401 }); + expect(config.offlineFallback).not.toHaveBeenCalled(); + expect(config.getAuthHeaders).not.toHaveBeenCalled(); + expect(next).not.toHaveBeenCalled(); + }); + + it('remote mode uses the existing authenticated transport pipeline', async () => { + const config = makeConfig({ enforceAuthAccessMode: true }); + setupInterceptor(config); + TestBed.inject(KitAuthAccessService).grantRemote(); + const response = new HttpResponse({ status: 200 }); + const next = vi.fn().mockReturnValue(of(response)); + + await expect(firstValueFrom(runInterceptor(baseReq, next))).resolves.toBe(response); + expect(config.getAuthHeaders).toHaveBeenCalledOnce(); + expect(next).toHaveBeenCalledOnce(); + }); + + it.each([ + [401, 'onUnauthorized'], + [403, 'onForbidden'], + ] as const)('HTTP %i revokes remote access and bypasses even a broad offline fallback', async (status, hook) => { + const fallbackResponse = new HttpResponse({ status: 200, body: 'cached' }); + const config = makeConfig({ + enforceAuthAccessMode: true, + offlineFallback: vi.fn().mockReturnValue(of(fallbackResponse)), + }); + setupInterceptor(config); + const access = TestBed.inject(KitAuthAccessService); + access.grantRemote(); + const error = new HttpErrorResponse({ status }); + const next = vi.fn().mockReturnValue(throwError(() => error)); + + await expect(firstValueFrom(runInterceptor(baseReq, next))).rejects.toBe(error); + + expect(access.mode).toBe('none'); + expect(config.offlineFallback).not.toHaveBeenCalled(); + expect(config[hook]).toHaveBeenCalledOnce(); + }); + + it('revokes remote access when auth header generation returns an explicit denial', async () => { + const denial = { status: 401 }; + const config = makeConfig({ + enforceAuthAccessMode: true, + getAuthHeaders: vi.fn().mockRejectedValue(denial), + onAuthError: vi.fn(), + }); + setupInterceptor(config); + const access = TestBed.inject(KitAuthAccessService); + access.grantRemote(); + const next = vi.fn(); + + await expect(firstValueFrom(runInterceptor(baseReq, next))).rejects.toBe(denial); + + expect(access.mode).toBe('none'); + expect(config.onAuthError).toHaveBeenCalledWith(baseReq, denial); + expect(next).not.toHaveBeenCalled(); + }); + }); + // ---- 401 handling --------------------------------------------------------- describe('401 Unauthorized', () => { it('calls onUnauthorized and re-throws the error', async () => { diff --git a/projects/kit/src/lib/http/kit-http.interceptor.ts b/projects/kit/src/lib/http/kit-http.interceptor.ts index 70f0687..3bdf584 100644 --- a/projects/kit/src/lib/http/kit-http.interceptor.ts +++ b/projects/kit/src/lib/http/kit-http.interceptor.ts @@ -6,6 +6,7 @@ import { Network } from '@capacitor/network'; import type { Observable } from 'rxjs'; import { from, retry, throwError, timer } from 'rxjs'; import { catchError, map, mergeMap, tap, timeout } from 'rxjs/operators'; +import { isExplicitAuthDenial, KitAuthAccessService } from '../auth/auth-access.service'; /** * HTTP methods that are safe to retry automatically. @@ -57,8 +58,7 @@ const DEFAULT_TIMEOUT_MS = 60_000; * @param error - The failed response. * @returns Whether this is a maintenance response. */ -export const isMaintenanceError = (error: HttpErrorResponse): boolean => - error.status === 503 && error.error?.code === 'MAINTENANCE'; +export const isMaintenanceError = (error: HttpErrorResponse): boolean => error.status === 503 && error.error?.code === 'MAINTENANCE'; /** * Parse a `Retry-After` header (delta-seconds or an HTTP-date) into milliseconds, or `null` when it @@ -97,6 +97,16 @@ const parseRetryAfterMs = (error: HttpErrorResponse): number | null => { * only the behavior that actually differs from the canonical baseline. */ export interface KitHttpConfig { + /** + * Enforce the shared auth access mode before generating headers or using transport. + * + * @remarks + * Optional and `false` by default for backward compatibility. In `local` mode the interceptor + * consults `offlineFallback` without calling `getAuthHeaders` or transport. In `none` mode it + * rejects without exposing local data. Real HTTP/auth-header 401/403 responses revoke access and + * bypass `offlineFallback`. + */ + enforceAuthAccessMode?: boolean; /** * Produce authentication and metadata headers for the outgoing request. * @@ -154,6 +164,7 @@ export interface KitHttpConfig { * @remarks * Returning a non-null observable replaces the error with that response (for example a queued * offline result). Optional; defaults to `null` (no fallback, normal error handling proceeds). + * With shared access enforcement enabled, explicit 401/403 denials are never passed here. * * @param request - The request that failed (after headers were applied). * @param error - The error response that triggered the fallback. @@ -336,7 +347,8 @@ const dispatchError = (config: KitHttpConfig, req: HttpRequest, error: * `Idempotency-Key`), and the status is a {@link RETRYABLE_STATUSES | transient status}. The * backoff is `retryCount * 500ms` plus up to 250ms of jitter, or the server's `Retry-After`. * When the device is offline it stops retrying immediately. - * 5. On the final error, `offlineFallback` is consulted first; otherwise the error is classified by + * 5. On the final error, enforced 401/403 revokes shared access and is rejected before fallback. + * For every other error, `offlineFallback` is consulted first; otherwise the error is classified by * status (see {@link dispatchError}): `401`→`onUnauthorized`, `403`→`onForbidden`, `0`→ * `onNetworkError` (when connected), `429`→`onRateLimited`, maintenance `503`→`onMaintenance`, * other `502`/`503`/`504`→`onServerBusy`, and `400`/`422`/`500` with a body message→`onServerError`. @@ -352,14 +364,32 @@ const dispatchError = (config: KitHttpConfig, req: HttpRequest, error: */ export const kitAuthInterceptor: HttpInterceptorFn = (request, next) => { const config = inject(KIT_HTTP_CONFIG); + const access = inject(KitAuthAccessService); if (config.bypass?.(request)) { return next(request); } + if (config.enforceAuthAccessMode && access.mode !== 'remote') { + const error = new HttpErrorResponse({ + // `local` deliberately looks like a transport failure so an outer offline read interceptor + // may resolve it. `none` must not use status 0, otherwise that interceptor could expose a + // persisted replica before authentication. + status: access.mode === 'local' ? 0 : 401, + statusText: access.mode === 'local' ? 'Local access only' : 'Authentication required', + url: request.url, + }); + if (access.mode === 'local') { + const fallback = config.offlineFallback?.(request, error); + if (fallback) return fallback; + } + return throwError(() => error); + } + return from(Promise.resolve(config.getAuthHeaders(request))).pipe( catchError((headerError: unknown) => { // getAuthHeaders failed → the request is never sent; classify it instead of failing silently. + if (config.enforceAuthAccessMode && isExplicitAuthDenial(headerError)) access.clear(); config.onAuthError?.(request, headerError); return throwError(() => headerError); }), @@ -409,6 +439,11 @@ export const kitAuthInterceptor: HttpInterceptorFn = (request, next) => { } }), catchError((error: HttpErrorResponse) => { + if (config.enforceAuthAccessMode && isExplicitAuthDenial(error)) { + access.clear(); + dispatchError(config, req, error); + return throwError(() => error); + } const fallback = config.offlineFallback?.(req, error); if (fallback) { return fallback; diff --git a/projects/kit/src/lib/realtime/kit-realtime-connection.spec.ts b/projects/kit/src/lib/realtime/kit-realtime-connection.spec.ts index a1191d2..945fdfc 100644 --- a/projects/kit/src/lib/realtime/kit-realtime-connection.spec.ts +++ b/projects/kit/src/lib/realtime/kit-realtime-connection.spec.ts @@ -3,6 +3,7 @@ import { Injectable } from '@angular/core'; import { TestBed } from '@angular/core/testing'; import { afterEach, describe, expect, it, vi } from 'vitest'; import { kitRealtimeProtocols, KitRealtimeConnection, KitRealtimeLivenessWatchdog, toKitWebSocketUrl } from './kit-realtime-connection'; +import { KitAuthAccessService } from '../auth/auth-access.service'; interface TestEvent { topic: string; @@ -43,9 +44,14 @@ class TestConnection extends KitRealtimeConnection { readonly removeAppListener = vi.fn(() => Promise.resolve()); readonly removeNetworkListener = vi.fn(() => Promise.resolve()); appListenerResolver: ((handle: PluginListenerHandle) => void) | null = null; + requireRemoteAccess = false; - protected override get realtimeOptions(): { clientId: string } { - return { clientId: 'self' }; + constructor(access?: KitAuthAccessService) { + super(access); + } + + protected override get realtimeOptions(): { clientId: string; requireRemoteAccess: boolean } { + return { clientId: 'self', requireRemoteAccess: this.requireRemoteAccess }; } protected get shouldConnect(): boolean { @@ -145,6 +151,25 @@ describe('KitRealtimeConnection', () => { expect(TestBed.inject(InheritedConstructorConnection)).toBeInstanceOf(InheritedConstructorConnection); }); + it('opens only in remote mode and suspends an authenticated socket on local mode', async () => { + const access = new KitAuthAccessService(); + const connection = new TestConnection(access); + connection.requireRemoteAccess = true; + + const start = connection.startConnectionForTest(); + connection.appListenerResolver?.({ remove: vi.fn() }); + await start; + expect(connection.sockets).toHaveLength(0); + + access.grantRemote(); + await vi.waitFor(() => expect(connection.sockets).toHaveLength(1)); + connection.sockets[0].open(); + expect(connection.isStreamOpen).toBe(true); + + access.grantLocal(); + expect(connection.isStreamOpen).toBe(false); + }); + it('pings all targets and reconnects only the target that closes', async () => { vi.useFakeTimers(); const connection = new TestConnection(); diff --git a/projects/kit/src/lib/realtime/kit-realtime-connection.ts b/projects/kit/src/lib/realtime/kit-realtime-connection.ts index 6da14f9..62cbf08 100644 --- a/projects/kit/src/lib/realtime/kit-realtime-connection.ts +++ b/projects/kit/src/lib/realtime/kit-realtime-connection.ts @@ -1,9 +1,10 @@ import { App } from '@capacitor/app'; import type { PluginListenerHandle } from '@capacitor/core'; import { Network } from '@capacitor/network'; -import { Injectable } from '@angular/core'; +import { Injectable, Optional } from '@angular/core'; import type { Observable } from 'rxjs'; -import { Subject } from 'rxjs'; +import { Subject, Subscription } from 'rxjs'; +import { KitAuthAccessService } from '../auth/auth-access.service'; /** One WebSocket endpoint and its ordered subprotocol list. URLs must be unique within a target set. */ export interface KitRealtimeSocketTarget { @@ -28,6 +29,8 @@ export interface KitRealtimeConnectionOptions { openTimeoutMs?: number; pingIntervalMs?: number; livenessTimeoutMs?: number; + /** Require shared `remote` auth access before opening or retaining sockets. */ + requireRemoteAccess?: boolean; } interface SocketHealth { @@ -120,6 +123,11 @@ export abstract class KitRealtimeConnection { #isNetworkConnected = true; #lifecycleRegistration: Promise | null = null; #lifecycleGeneration = 0; + #accessSubscription: Subscription | null = null; + + // Constructor injection keeps direct `new` compatibility for existing subclasses and tests. + // eslint-disable-next-line @angular-eslint/prefer-inject + constructor(@Optional() private readonly authAccess: KitAuthAccessService | null = null) {} /** All parsed events received by this connection. */ readonly events$: Observable> = this.#events$.asObservable(); @@ -141,6 +149,7 @@ export abstract class KitRealtimeConnection { openTimeoutMs: options.openTimeoutMs ?? 15_000, pingIntervalMs: options.pingIntervalMs ?? 30_000, livenessTimeoutMs: options.livenessTimeoutMs ?? 70_000, + requireRemoteAccess: options.requireRemoteAccess ?? false, }; } @@ -170,7 +179,8 @@ export abstract class KitRealtimeConnection { protected abstract buildSocketTargets(): Promise; get #canOpen(): boolean { - return this.shouldConnect && this.#isAppActive && this.#isNetworkConnected; + const hasRemoteAccess = !this.#options.requireRemoteAccess || (this.authAccess !== null && this.authAccess.mode === 'remote'); + return this.shouldConnect && this.#isAppActive && this.#isNetworkConnected && hasRemoteAccess; } /** Hook used by authenticated clients to invalidate a token after handshake failure. */ @@ -264,6 +274,7 @@ export abstract class KitRealtimeConnection { /** Register lifecycle listeners and establish the currently requested targets. */ protected async startConnection(): Promise { + this.#registerAccessListener(); await this.registerLifecycleListeners(); if (!this.shouldConnect) { this.removeLifecycleListeners(); @@ -277,6 +288,8 @@ export abstract class KitRealtimeConnection { this.resetConnectionState(); this.suspend(); this.removeLifecycleListeners(); + this.#accessSubscription?.unsubscribe(); + this.#accessSubscription = null; } /** Rebuild targets while preserving the owning session's connection intent and listeners. */ @@ -375,6 +388,17 @@ export abstract class KitRealtimeConnection { } } + #registerAccessListener(): void { + if (!this.#options.requireRemoteAccess || !this.authAccess || this.#accessSubscription) return; + this.#accessSubscription = this.authAccess.mode$.subscribe((mode) => { + if (mode === 'remote' && this.#canOpen) { + void this.open(); + } else { + this.suspend(); + } + }); + } + #connectionFailed(generation: number, socket: WebSocket): void { const health = this.#health.get(socket); if (generation !== this.#generation || !health || this.#sockets.get(health.key) !== socket) { diff --git a/projects/kit/src/public-api.ts b/projects/kit/src/public-api.ts index d73ac4b..3e76786 100644 --- a/projects/kit/src/public-api.ts +++ b/projects/kit/src/public-api.ts @@ -33,6 +33,7 @@ export * from './lib/keyboard/kit-keyboard'; // only pulled in by apps that import those subpaths. // Auth: functional route guards. +export * from './lib/auth/auth-access.service'; export * from './lib/auth/auth-guards'; // HTTP: functional interceptor.