diff --git a/projects/kit/README.md b/projects/kit/README.md index b675540..fd919b2 100644 --- a/projects/kit/README.md +++ b/projects/kit/README.md @@ -633,7 +633,7 @@ fields and ignored server fields are never persisted. ```typescript // app.config.ts import { provideHttpClient, withInterceptors } from '@angular/common/http'; -import { kitAuthInterceptor, provideKitHttp, KitReloadAlertController } from '@rdlabo/ionic-angular-kit'; +import { isIdentityAuthFailure, kitAuthInterceptor, provideKitHttp, KitReloadAlertController } from '@rdlabo/ionic-angular-kit'; export const appConfig: ApplicationConfig = { providers: [ @@ -645,13 +645,17 @@ export const appConfig: ApplicationConfig = { // Required for the new offline auth boundary. Kept opt-in so existing applications retain // their current interceptor behavior until they wire KitAuthAccessService. enforceAuthAccessMode: true, - // Optional. Use this only when the API distinguishes invalid identity (401) from - // authenticated resource permission (403). Omission keeps the legacy 401/403 revoke policy. - isAuthAccessDenial: (_req, error) => error.status === 401, + // Only an explicitly tagged global identity failure may revoke shared access. + // Omission keeps the legacy 401/403 revoke policy for existing applications. + isAuthAccessDenial: (_req, error) => isIdentityAuthFailure(error), getAuthHeaders: async (req) => ({ Authorization: `Bearer ${await auth.getToken()}`, }), - onUnauthorized: (req) => auth.signOut(), + // Status-independent: handles both new identity 401 and tagged historical identity 403. + onAuthAccessDenial: () => auth.signOut(), + onUnauthorized: (_req, error) => { + // Feature UX for reauthentication/credential failures may inspect the error here. + }, // Fleet-canonical "network error → offer reload" (see KitReloadAlertController). onNetworkError: (status) => reload.present({ @@ -698,6 +702,36 @@ because that also skips authentication headers and error handling, and do not gl Plus: a `getAuthHeaders` rejection → `onAuthError(request, error)` (the request is never sent). +The shared `401` body carries `authFailureScope`: + +- `identity`: the Firebase/global identity cannot be established; global session and replica/outbox + invalidation is allowed. +- `reauthentication`: the identity remains valid, but a recent sign-in/step-up is required. +- `credential`: only a feature-owned delegated credential is invalid. + +`getAuthFailureScope(error)` reads this explicit field and returns `null` for untagged legacy +responses. It also accepts an explicitly tagged historical `403` identity failure for products +whose installed clients still require that status; new APIs use `401`. +`isIdentityAuthFailure(error)` is therefore intentionally strict. Put destructive global cleanup in +`onAuthAccessDenial`; it runs for a classified identity failure independently of whether a compatible +server returned 401 or 403. `onUnauthorized` receives the complete `HttpErrorResponse` for status-specific UX. +An untagged `403` remains a resource/scope/business permission failure and never implies global +identity loss. Existing callbacks that accept only the request remain source-compatible, and applications +that omit `isAuthAccessDenial` retain the historical 401/403 revocation behavior. + +Deploy the API contract before enabling the strict client classifier: + +| Combination | Result | +| ---------------------------- | ----------------------------------------------------------------------------------------------------------- | +| Tagged API + legacy client | The extra body fields are ignored and the client's existing status behavior is preserved. | +| Untagged API + strict client | Global identity loss cannot be identified safely, so the client retains local identity state. | +| Tagged API + strict client | Only `identity` with `AUTH_IDENTITY_INVALID` revokes global access; narrower failures remain feature-owned. | + +For products whose installed clients historically require auth failure as `403`, deploy an explicitly +tagged legacy `403` identity response first. The strict classifier accepts it only with the matching +`statusCode`, scope, and `AUTH_IDENTITY_INVALID` code. Do not infer identity from an untagged legacy +401/403: that would collapse `credential` and `reauthentication` back into destructive global logout. + **Note (0.0.9):** `onNetworkError` is now narrowed to genuine network failures (status `0`); `502/503/429` moved to `onServerBusy`/`onRateLimited`. Existing configs stay valid — they just fire less often — so adopt the new hooks only if you want to distinguish server-busy / rate-limit from a connection loss. ### KitReloadAlertController diff --git a/projects/kit/src/lib/http/auth-failure.spec.ts b/projects/kit/src/lib/http/auth-failure.spec.ts new file mode 100644 index 0000000..96b6328 --- /dev/null +++ b/projects/kit/src/lib/http/auth-failure.spec.ts @@ -0,0 +1,82 @@ +import { HttpErrorResponse } from '@angular/common/http'; +import { describe, expect, it } from 'vitest'; +import { getAuthFailureScope, isIdentityAuthFailure } from './auth-failure'; + +describe('auth failure protocol', () => { + it.each(['identity', 'reauthentication', 'credential'] as const)('reads %s from an HTTP 401', (scope) => { + const code = scope === 'identity' ? 'AUTH_IDENTITY_INVALID' : 'DOMAIN_CODE'; + const error = new HttpErrorResponse({ + status: 401, + error: { + statusCode: 401, + message: 'Unauthorized', + code, + authFailureScope: scope, + }, + }); + + expect(getAuthFailureScope(error)).toBe(scope); + expect(isIdentityAuthFailure(error)).toBe(scope === 'identity'); + }); + + it('does not infer a destructive identity failure from a legacy status alone', () => { + expect(getAuthFailureScope(new HttpErrorResponse({ status: 401 }))).toBeNull(); + expect(isIdentityAuthFailure({ status: 403, error: { authFailureScope: 'identity' } })).toBe(false); + }); + + it('rejects a mismatched body status or an identity scope without the standard identity code', () => { + expect( + getAuthFailureScope({ + status: 401, + error: { statusCode: 403, code: 'AUTH_IDENTITY_INVALID', authFailureScope: 'identity' }, + }), + ).toBeNull(); + expect( + getAuthFailureScope({ + status: 403, + error: { statusCode: 403, code: 'BUSINESS_FORBIDDEN', authFailureScope: 'identity' }, + }), + ).toBeNull(); + }); + + it('accepts a body-like value for non-Angular consumers', () => { + expect( + getAuthFailureScope({ + statusCode: 401, + message: 'Unauthorized', + code: 'AUTH_IDENTITY_INVALID', + authFailureScope: 'identity', + }), + ).toBe('identity'); + }); + + it('accepts an explicitly tagged historical 403 identity failure', () => { + expect( + isIdentityAuthFailure( + new HttpErrorResponse({ + status: 403, + error: { + statusCode: 403, + message: 'Forbidden resource', + code: 'AUTH_IDENTITY_INVALID', + authFailureScope: 'identity', + }, + }), + ), + ).toBe(true); + }); + + it.each(['reauthentication', 'credential'] as const)('rejects %s on the legacy 403 exception', (scope) => { + expect( + getAuthFailureScope({ + status: 403, + error: { + statusCode: 403, + message: 'Forbidden', + code: 'DOMAIN_CODE', + authFailureScope: scope, + }, + }), + ).toBeNull(); + }); +}); diff --git a/projects/kit/src/lib/http/auth-failure.ts b/projects/kit/src/lib/http/auth-failure.ts new file mode 100644 index 0000000..7d96a32 --- /dev/null +++ b/projects/kit/src/lib/http/auth-failure.ts @@ -0,0 +1,53 @@ +/** + * Shared lifecycle boundary carried by an authentication failure response. + * + * Only `identity` invalidates the application's global authenticated identity. + * The other scopes retain it and let the owning feature handle recovery. + */ +export const AUTH_FAILURE_SCOPES = { + identity: 'identity', + reauthentication: 'reauthentication', + credential: 'credential', +} as const; + +export type AuthFailureScope = (typeof AUTH_FAILURE_SCOPES)[keyof typeof AUTH_FAILURE_SCOPES]; + +export const AUTH_IDENTITY_INVALID_CODE = 'AUTH_IDENTITY_INVALID'; + +export interface AuthFailureBody { + statusCode: 401 | 403; + message: string; + code: string; + authFailureScope: AuthFailureScope; +} + +type UnknownRecord = Record; + +const isRecord = (value: unknown): value is UnknownRecord => typeof value === 'object' && value !== null; + +const isAuthFailureScope = (value: unknown): value is AuthFailureScope => + Object.values(AUTH_FAILURE_SCOPES).some((scope) => scope === value); + +/** + * Reads the explicit auth-failure scope from an Angular `HttpErrorResponse` or + * from a body-like value. Tagged historical 403 identity responses are + * supported for installed-client compatibility. Untagged 401/403 responses + * intentionally return `null`. + */ +export const getAuthFailureScope = (error: unknown): AuthFailureScope | null => { + if (!isRecord(error)) return null; + + const body = isRecord(error['error']) ? error['error'] : error; + const status = typeof error['status'] === 'number' ? error['status'] : body['statusCode']; + if (status !== 401 && status !== 403) return null; + if (body['statusCode'] !== status || typeof body['code'] !== 'string') return null; + + const scope = body['authFailureScope']; + if (!isAuthFailureScope(scope)) return null; + if (status === 403 && scope !== AUTH_FAILURE_SCOPES.identity) return null; + if (scope === AUTH_FAILURE_SCOPES.identity && body['code'] !== AUTH_IDENTITY_INVALID_CODE) return null; + return scope; +}; + +/** True only for an explicitly tagged global identity failure. */ +export const isIdentityAuthFailure = (error: unknown): boolean => getAuthFailureScope(error) === AUTH_FAILURE_SCOPES.identity; 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 3bb1f28..3735297 100644 --- a/projects/kit/src/lib/http/kit-http.interceptor.spec.ts +++ b/projects/kit/src/lib/http/kit-http.interceptor.spec.ts @@ -7,6 +7,7 @@ import { firstValueFrom } from 'rxjs'; import { KIT_AUTH_BOOTSTRAP_REQUEST, kitAuthInterceptor, provideKitHttp, type KitHttpConfig } from './kit-http.interceptor'; import { KitAuthAccessService } from '../auth/auth-access.service'; +import { isIdentityAuthFailure } from './auth-failure'; // --------------------------------------------------------------------------- // Mock @capacitor/network so Network.getStatus() never hits native code. @@ -300,7 +301,7 @@ describe('kitAuthInterceptor', () => { await expect(firstValueFrom(runInterceptor(baseReq, next))).rejects.toBe(error403); expect(access.mode).toBe('remote'); - expect(config.onForbidden).toHaveBeenCalledOnce(); + expect(config.onForbidden).toHaveBeenCalledWith(baseReq, error403); expect(config.onUnauthorized).not.toHaveBeenCalled(); expect(config.offlineFallback).not.toHaveBeenCalled(); expect(config.isAuthAccessDenial).toHaveBeenCalledWith(baseReq, error403); @@ -320,7 +321,7 @@ describe('kitAuthInterceptor', () => { await expect(firstValueFrom(runInterceptor(baseReq, next))).rejects.toBe(error401); expect(access.mode).toBe('none'); - expect(config.onUnauthorized).toHaveBeenCalledOnce(); + expect(config.onUnauthorized).toHaveBeenCalledWith(baseReq, error401); expect(config.isAuthAccessDenial).toHaveBeenCalledWith(baseReq, error401); }); @@ -341,6 +342,105 @@ describe('kitAuthInterceptor', () => { expect(config[hook]).toHaveBeenCalledOnce(); } }); + + it.each([ + ['identity', 'AUTH_IDENTITY_INVALID', true], + ['reauthentication', 'STEP_UP_REQUIRED', false], + ['credential', 'PUBLIC_BOOKING_SESSION_INVALID', false], + [undefined, undefined, false], + ] as const)('strict auth scope keeps global access unless scope is %s', async (authFailureScope, code, shouldClear) => { + const error = new HttpErrorResponse({ + status: 401, + error: + authFailureScope === undefined + ? { statusCode: 401, message: 'Legacy unauthorized' } + : { statusCode: 401, message: 'Unauthorized', code, authFailureScope }, + }); + const config = makeConfig({ + enforceAuthAccessMode: true, + isAuthAccessDenial: (_req, candidate) => isIdentityAuthFailure(candidate), + }); + setupInterceptor(config); + const access = TestBed.inject(KitAuthAccessService); + access.grantRemote(); + const next = vi.fn().mockReturnValue(throwError(() => error)); + + await expect(firstValueFrom(runInterceptor(baseReq, next))).rejects.toBe(error); + + expect(access.mode).toBe(shouldClear ? 'none' : 'remote'); + expect(config.onUnauthorized).toHaveBeenCalledWith(baseReq, error); + }); + + it('does not revoke for a 403 that falsely claims identity without the standard code', async () => { + const error = new HttpErrorResponse({ + status: 403, + error: { + statusCode: 403, + message: 'Forbidden', + code: 'BUSINESS_FORBIDDEN', + authFailureScope: 'identity', + }, + }); + const config = makeConfig({ + enforceAuthAccessMode: true, + isAuthAccessDenial: (_req, candidate) => isIdentityAuthFailure(candidate), + }); + setupInterceptor(config); + const access = TestBed.inject(KitAuthAccessService); + access.grantRemote(); + const next = vi.fn().mockReturnValue(throwError(() => error)); + + await expect(firstValueFrom(runInterceptor(baseReq, next))).rejects.toBe(error); + + expect(access.mode).toBe('remote'); + expect(config.onForbidden).toHaveBeenCalledWith(baseReq, error); + }); + + it('runs status-independent identity cleanup for a tagged legacy 403', async () => { + const error = new HttpErrorResponse({ + status: 403, + error: { + statusCode: 403, + message: 'Forbidden resource', + code: 'AUTH_IDENTITY_INVALID', + authFailureScope: 'identity', + }, + }); + const config = makeConfig({ + enforceAuthAccessMode: true, + isAuthAccessDenial: (_req, candidate) => isIdentityAuthFailure(candidate), + onAuthAccessDenial: vi.fn(), + }); + setupInterceptor(config); + const access = TestBed.inject(KitAuthAccessService); + access.grantRemote(); + const next = vi.fn().mockReturnValue(throwError(() => error)); + + await expect(firstValueFrom(runInterceptor(baseReq, next))).rejects.toBe(error); + + expect(access.mode).toBe('none'); + expect(config.onAuthAccessDenial).toHaveBeenCalledWith(baseReq, error); + expect(config.onForbidden).toHaveBeenCalledWith(baseReq, error); + }); + + it('sends a normal business 403 only to permission handling', async () => { + const error = new HttpErrorResponse({ status: 403, error: { statusCode: 403, code: 'NOT_ALLOWED' } }); + const config = makeConfig({ + enforceAuthAccessMode: true, + isAuthAccessDenial: (_req, candidate) => isIdentityAuthFailure(candidate), + onAuthAccessDenial: vi.fn(), + }); + setupInterceptor(config); + const access = TestBed.inject(KitAuthAccessService); + access.grantRemote(); + const next = vi.fn().mockReturnValue(throwError(() => error)); + + await expect(firstValueFrom(runInterceptor(baseReq, next))).rejects.toBe(error); + + expect(access.mode).toBe('remote'); + expect(config.onAuthAccessDenial).not.toHaveBeenCalled(); + expect(config.onForbidden).toHaveBeenCalledWith(baseReq, error); + }); }); // ---- 401 handling --------------------------------------------------------- @@ -353,7 +453,7 @@ describe('kitAuthInterceptor', () => { const next = vi.fn().mockReturnValue(throwError(() => error401)); await expect(firstValueFrom(runInterceptor(baseReq, next))).rejects.toThrow(); - expect(config.onUnauthorized).toHaveBeenCalledOnce(); + expect(config.onUnauthorized).toHaveBeenCalledWith(baseReq, error401); expect(config.onForbidden).not.toHaveBeenCalled(); }); }); @@ -368,7 +468,7 @@ describe('kitAuthInterceptor', () => { const next = vi.fn().mockReturnValue(throwError(() => error403)); await expect(firstValueFrom(runInterceptor(baseReq, next))).rejects.toThrow(); - expect(config.onForbidden).toHaveBeenCalledOnce(); + expect(config.onForbidden).toHaveBeenCalledWith(baseReq, error403); expect(config.onUnauthorized).not.toHaveBeenCalled(); }); }); diff --git a/projects/kit/src/lib/http/kit-http.interceptor.ts b/projects/kit/src/lib/http/kit-http.interceptor.ts index b2c3d96..e89ee78 100644 --- a/projects/kit/src/lib/http/kit-http.interceptor.ts +++ b/projects/kit/src/lib/http/kit-http.interceptor.ts @@ -190,8 +190,9 @@ export interface KitHttpConfig { * Optional; defaults to a no-op. * * @param request - The request that received the `401`. + * @param error - The complete response, including any explicit authentication failure scope. */ - onUnauthorized?(request: HttpRequest): void; + onUnauthorized?(request: HttpRequest, error: HttpErrorResponse): void; /** * Side effect to run on a `403` response (a permission error). * @@ -199,8 +200,9 @@ export interface KitHttpConfig { * Optional; defaults to a no-op. * * @param request - The request that received the `403`. + * @param error - The complete permission-error response. */ - onForbidden?(request: HttpRequest): void; + onForbidden?(request: HttpRequest, error: HttpErrorResponse): void; /** * UX hook for a genuine network / transport failure (status `0`) while the device reports itself * connected — i.e. the server is unreachable rather than the phone being offline. @@ -270,6 +272,17 @@ export interface KitHttpConfig { * @param error - The error thrown by `getAuthHeaders`. */ onAuthError?(request: HttpRequest, error: unknown): void; + /** + * Called when {@link KitHttpConfig.isAuthAccessDenial} classifies a failure as + * global identity loss and shared remote access is revoked. + * + * @remarks + * This hook is status-independent so an explicitly tagged historical 403 + * identity response reaches the same cleanup as a new 401 identity response. + * Status hooks still run afterward for response-specific UX. Optional and + * backward compatible. + */ + onAuthAccessDenial?(request: HttpRequest, error: unknown): void; /** * Classify whether an auth-related failure should revoke shared remote access. * @@ -357,9 +370,9 @@ const dispatchError = (config: KitHttpConfig, req: HttpRequest, error: const retryAfterSeconds = retryAfterMs === null ? undefined : Math.round(retryAfterMs / 1000); if (status === 401) { - config.onUnauthorized?.(req); + config.onUnauthorized?.(req, error); } else if (status === 403) { - config.onForbidden?.(req); + config.onForbidden?.(req, error); } else if (status === 0) { // Genuine network/transport failure. Only surface it when the device is actually connected // (server unreachable); when offline, offlineFallback owns the UX — a reload prompt won't help. @@ -439,6 +452,7 @@ export const kitAuthInterceptor: HttpInterceptorFn = (request, next) => { // getAuthHeaders failed → the request is never sent; classify it instead of failing silently. if (config.enforceAuthAccessMode && isExplicitAuthDenial(headerError) && shouldRevokeAuthAccess(config, request, headerError)) { access.clear(); + config.onAuthAccessDenial?.(request, headerError); } config.onAuthError?.(request, headerError); return throwError(() => headerError); @@ -490,7 +504,10 @@ export const kitAuthInterceptor: HttpInterceptorFn = (request, next) => { }), catchError((error: HttpErrorResponse) => { if (config.enforceAuthAccessMode && isExplicitAuthDenial(error)) { - if (shouldRevokeAuthAccess(config, req, error)) access.clear(); + if (shouldRevokeAuthAccess(config, req, error)) { + access.clear(); + config.onAuthAccessDenial?.(req, error); + } dispatchError(config, req, error); return throwError(() => error); } diff --git a/projects/kit/src/public-api.ts b/projects/kit/src/public-api.ts index 3e76786..dc440dc 100644 --- a/projects/kit/src/public-api.ts +++ b/projects/kit/src/public-api.ts @@ -38,6 +38,7 @@ export * from './lib/auth/auth-guards'; // HTTP: functional interceptor. export * from './lib/http/kit-http.interceptor'; +export * from './lib/http/auth-failure'; // Realtime: reconnecting Hibernation WebSocket client infrastructure. export * from './lib/realtime/kit-realtime-connection';