Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 16 additions & 6 deletions projects/kit/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -419,17 +419,15 @@ 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$,
reauthenticate: async () => {
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;
Expand Down Expand Up @@ -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`):
Expand Down
50 changes: 48 additions & 2 deletions projects/kit/src/lib/http/kit-http.interceptor.spec.ts
Original file line number Diff line number Diff line change
@@ -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';

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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'],
Expand Down
25 changes: 20 additions & 5 deletions projects/kit/src/lib/http/kit-http.interceptor.ts
Original file line number Diff line number Diff line change
@@ -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<boolean>(() => false);

/**
* HTTP methods that are safe to retry automatically.
*
Expand Down Expand Up @@ -365,12 +377,13 @@ const dispatchError = (config: KitHttpConfig, req: HttpRequest<unknown>, 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
Expand Down Expand Up @@ -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);
Expand Down