diff --git a/projects/kit/README.md b/projects/kit/README.md index 3e14729..e70fe05 100644 --- a/projects/kit/README.md +++ b/projects/kit/README.md @@ -419,8 +419,7 @@ provideKitAuth(() => { resume: () => offline.resumeRemoteSession(), }; }, - onUnavailable: async (_state, _error, lease) => - (await offline.activateOfflineSession(auth.currentSubject(), lease)) !== null, + onUnavailable: async (_state, _error, lease) => (await offline.activateOfflineSession(auth.currentSubject(), lease)) !== null, isUnavailableError: isOfflineFallbackError, remoteRecovery: { availability: () => auth.authorityAvailable$, @@ -428,8 +427,7 @@ provideKitAuth(() => { const session = await auth.tryExchangeCredential(); return session ? { - activate: (lease) => - offline.prepareRemoteSession(session.userId, session.groupIds, session.subject, lease), + activate: (lease) => offline.prepareRemoteSession(session.userId, session.groupIds, session.subject, lease), resume: () => offline.resumeRemoteSession(), } : false; @@ -669,8 +667,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 +`withInterceptors([offlineInterceptor, kitAuthInterceptor])` in that order. The credential-exchange +request that must run before `remote` is granted sets `KIT_AUTH_BOOTSTRAP_REQUEST` in its +`HttpContext`, together with the offline entry point's `OFFLINE_BYPASS`: + +```ts +const context = new HttpContext().set(KIT_AUTH_BOOTSTRAP_REQUEST, true).set(OFFLINE_BYPASS, true); +http.post('/login', body, { context }); +``` + +It still receives authentication headers and uses the normal denial/error pipeline; only the +pre-existing `remote` requirement is deferred. The auth interceptor never consults its configured +offline fallback for this request, while `OFFLINE_BYPASS` prevents an outer offline interceptor +from replacing a transport failure with local data. Do not use the broader HTTP `bypass` hook, +because that also skips authentication headers and error handling, and do not globally relax `enforceAuthAccessMode`. **Error dispatch** (after retries, in `catchError`): 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 76996f4..7149194 100644 --- a/projects/kit/src/lib/http/kit-http.interceptor.spec.ts +++ b/projects/kit/src/lib/http/kit-http.interceptor.spec.ts @@ -1,11 +1,11 @@ import { provideZonelessChangeDetection } from '@angular/core'; import { TestBed } from '@angular/core/testing'; -import { HttpErrorResponse, HttpHeaders, HttpRequest, HttpResponse } from '@angular/common/http'; +import { HttpContext, HttpErrorResponse, HttpHeaders, HttpRequest, HttpResponse } from '@angular/common/http'; import { Observable } from 'rxjs'; import { of, throwError } from 'rxjs'; import { firstValueFrom } from 'rxjs'; -import { kitAuthInterceptor, provideKitHttp, type KitHttpConfig } from './kit-http.interceptor'; +import { KIT_AUTH_BOOTSTRAP_REQUEST, kitAuthInterceptor, provideKitHttp, type KitHttpConfig } from './kit-http.interceptor'; import { KitAuthAccessService } from '../auth/auth-access.service'; // --------------------------------------------------------------------------- @@ -175,6 +175,52 @@ describe('kitAuthInterceptor', () => { expect(next).toHaveBeenCalledOnce(); }); + it.each(['none', 'local'] as const)( + 'allows an explicitly marked auth bootstrap request from %s mode through the authenticated pipeline', + async (mode) => { + const config = makeConfig({ enforceAuthAccessMode: true }); + setupInterceptor(config); + const access = TestBed.inject(KitAuthAccessService); + if (mode === 'local') access.grantLocal(); + const request = baseReq.clone({ + context: new HttpContext().set(KIT_AUTH_BOOTSTRAP_REQUEST, true), + }); + const response = new HttpResponse({ status: 200 }); + const next = vi.fn().mockReturnValue(of(response)); + + await expect(firstValueFrom(runInterceptor(request, next))).resolves.toBe(response); + + expect(access.mode).toBe(mode); + expect(config.getAuthHeaders).toHaveBeenCalledWith(request); + expect(next).toHaveBeenCalledOnce(); + }, + ); + + it.each(['none', 'local'] as const)( + 'never turns a failed auth bootstrap request from %s mode into an offline success', + async (mode) => { + const fallbackResponse = new HttpResponse({ status: 200, body: 'local' }); + const config = makeConfig({ + enforceAuthAccessMode: true, + offlineFallback: vi.fn().mockReturnValue(of(fallbackResponse)), + }); + setupInterceptor(config); + const access = TestBed.inject(KitAuthAccessService); + if (mode === 'local') access.grantLocal(); + const request = new HttpRequest('POST', '/login', null, { + context: new HttpContext().set(KIT_AUTH_BOOTSTRAP_REQUEST, true), + }); + const error = new HttpErrorResponse({ status: 0 }); + const next = vi.fn().mockReturnValue(throwError(() => error)); + + await expect(firstValueFrom(runInterceptor(request, next))).rejects.toBe(error); + + expect(access.mode).toBe(mode); + expect(config.offlineFallback).not.toHaveBeenCalled(); + expect(config.onNetworkError).toHaveBeenCalledOnce(); + }, + ); + it.each([ [401, 'onUnauthorized'], [403, 'onForbidden'], diff --git a/projects/kit/src/lib/http/kit-http.interceptor.ts b/projects/kit/src/lib/http/kit-http.interceptor.ts index 3bdf584..75aba80 100644 --- a/projects/kit/src/lib/http/kit-http.interceptor.ts +++ b/projects/kit/src/lib/http/kit-http.interceptor.ts @@ -1,13 +1,25 @@ import type { EnvironmentProviders } from '@angular/core'; import { inject, InjectionToken, makeEnvironmentProviders } from '@angular/core'; import type { HttpEvent, HttpInterceptorFn, HttpRequest } from '@angular/common/http'; -import { HttpErrorResponse, HttpResponse } from '@angular/common/http'; +import { HttpContextToken, HttpErrorResponse, HttpResponse } from '@angular/common/http'; 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'; +/** + * Marks the narrowly scoped credential-exchange request that establishes remote access. + * + * @remarks + * When shared auth access enforcement is enabled, the application starts in `none` (or may be + * recovering from `local`) and therefore cannot make ordinary authenticated requests. The one + * server request that verifies the current provider credential must set this context token. It + * still runs the complete header, retry, denial, and error pipeline; only the pre-existing + * `remote` access requirement is deferred until that exchange succeeds. + */ +export const KIT_AUTH_BOOTSTRAP_REQUEST = new HttpContextToken(() => false); + /** * HTTP methods that are safe to retry automatically. * @@ -365,12 +377,13 @@ const dispatchError = (config: KitHttpConfig, req: HttpRequest, error: export const kitAuthInterceptor: HttpInterceptorFn = (request, next) => { const config = inject(KIT_HTTP_CONFIG); const access = inject(KitAuthAccessService); + const isAuthBootstrap = request.context.get(KIT_AUTH_BOOTSTRAP_REQUEST); if (config.bypass?.(request)) { return next(request); } - if (config.enforceAuthAccessMode && access.mode !== 'remote') { + if (config.enforceAuthAccessMode && access.mode !== 'remote' && !isAuthBootstrap) { 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 @@ -444,9 +457,11 @@ export const kitAuthInterceptor: HttpInterceptorFn = (request, next) => { dispatchError(config, req, error); return throwError(() => error); } - const fallback = config.offlineFallback?.(req, error); - if (fallback) { - return fallback; + if (!isAuthBootstrap) { + const fallback = config.offlineFallback?.(req, error); + if (fallback) { + return fallback; + } } dispatchError(config, req, error); return throwError(() => error);