From 4c8a43d7045a327a9c017fe1f99f07dfeca860b3 Mon Sep 17 00:00:00 2001 From: rdlabo Date: Sun, 26 Jul 2026 11:05:45 +0900 Subject: [PATCH 1/3] feat: classify authentication failure scopes --- projects/kit/README.md | 26 ++++++++-- .../kit/src/lib/http/auth-failure.spec.ts | 36 ++++++++++++++ projects/kit/src/lib/http/auth-failure.ts | 47 +++++++++++++++++++ .../src/lib/http/kit-http.interceptor.spec.ts | 8 ++-- .../kit/src/lib/http/kit-http.interceptor.ts | 10 ++-- projects/kit/src/public-api.ts | 1 + 6 files changed, 115 insertions(+), 13 deletions(-) create mode 100644 projects/kit/src/lib/http/auth-failure.spec.ts create mode 100644 projects/kit/src/lib/http/auth-failure.ts diff --git a/projects/kit/README.md b/projects/kit/README.md index b675540..c0f59fc 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,15 @@ 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(), + onUnauthorized: (_req, error) => { + if (isIdentityAuthFailure(error)) auth.signOut(); + }, // Fleet-canonical "network error → offer reload" (see KitReloadAlertController). onNetworkError: (status) => reload.present({ @@ -698,6 +700,20 @@ 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. `isIdentityAuthFailure(error)` is therefore intentionally strict. `onUnauthorized` +receives the complete `HttpErrorResponse`, allowing destructive callbacks to use the same boundary. +`403` always 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. + **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..7adb407 --- /dev/null +++ b/projects/kit/src/lib/http/auth-failure.spec.ts @@ -0,0 +1,36 @@ +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 error = new HttpErrorResponse({ + status: 401, + error: { + statusCode: 401, + message: 'Unauthorized', + code: 'DOMAIN_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('accepts a body-like value for non-Angular consumers', () => { + expect( + getAuthFailureScope({ + statusCode: 401, + message: 'Unauthorized', + code: 'AUTH_IDENTITY_INVALID', + authFailureScope: 'identity', + }), + ).toBe('identity'); + }); +}); 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..d2a9a30 --- /dev/null +++ b/projects/kit/src/lib/http/auth-failure.ts @@ -0,0 +1,47 @@ +/** + * Shared lifecycle boundary carried by an HTTP 401 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; + 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. Untagged legacy 401 responses intentionally return + * `null`; applications can then choose their own compatibility fallback. + */ +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) return null; + + return isAuthFailureScope(body['authFailureScope']) ? body['authFailureScope'] : null; +}; + +/** 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..cc6f457 100644 --- a/projects/kit/src/lib/http/kit-http.interceptor.spec.ts +++ b/projects/kit/src/lib/http/kit-http.interceptor.spec.ts @@ -300,7 +300,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 +320,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); }); @@ -353,7 +353,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 +368,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..0165cb2 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. @@ -357,9 +359,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. 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'; From a34050e729daa4f161ede0367d93f2d3346a8f65 Mon Sep 17 00:00:00 2001 From: rdlabo Date: Sun, 26 Jul 2026 12:02:03 +0900 Subject: [PATCH 2/3] test: cover scoped access revocation --- projects/kit/README.md | 21 ++++++-- .../kit/src/lib/http/auth-failure.spec.ts | 34 +++++++++++- projects/kit/src/lib/http/auth-failure.ts | 17 +++--- .../src/lib/http/kit-http.interceptor.spec.ts | 54 +++++++++++++++++++ 4 files changed, 116 insertions(+), 10 deletions(-) diff --git a/projects/kit/README.md b/projects/kit/README.md index c0f59fc..8d94204 100644 --- a/projects/kit/README.md +++ b/projects/kit/README.md @@ -708,12 +708,27 @@ The shared `401` body carries `authFailureScope`: - `credential`: only a feature-owned delegated credential is invalid. `getAuthFailureScope(error)` reads this explicit field and returns `null` for untagged legacy -responses. `isIdentityAuthFailure(error)` is therefore intentionally strict. `onUnauthorized` +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. `onUnauthorized` receives the complete `HttpErrorResponse`, allowing destructive callbacks to use the same boundary. -`403` always 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 +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 index 7adb407..e6c0c82 100644 --- a/projects/kit/src/lib/http/auth-failure.spec.ts +++ b/projects/kit/src/lib/http/auth-failure.spec.ts @@ -4,12 +4,13 @@ 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: 'DOMAIN_CODE', + code, authFailureScope: scope, }, }); @@ -23,6 +24,21 @@ describe('auth failure protocol', () => { 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({ @@ -33,4 +49,20 @@ describe('auth failure protocol', () => { }), ).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); + }); }); diff --git a/projects/kit/src/lib/http/auth-failure.ts b/projects/kit/src/lib/http/auth-failure.ts index d2a9a30..fb71037 100644 --- a/projects/kit/src/lib/http/auth-failure.ts +++ b/projects/kit/src/lib/http/auth-failure.ts @@ -1,5 +1,5 @@ /** - * Shared lifecycle boundary carried by an HTTP 401 response. + * 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. @@ -15,7 +15,7 @@ export type AuthFailureScope = (typeof AUTH_FAILURE_SCOPES)[keyof typeof AUTH_FA export const AUTH_IDENTITY_INVALID_CODE = 'AUTH_IDENTITY_INVALID'; export interface AuthFailureBody { - statusCode: 401; + statusCode: 401 | 403; message: string; code: string; authFailureScope: AuthFailureScope; @@ -30,17 +30,22 @@ const isAuthFailureScope = (value: unknown): value is AuthFailureScope => /** * Reads the explicit auth-failure scope from an Angular `HttpErrorResponse` or - * from a body-like value. Untagged legacy 401 responses intentionally return - * `null`; applications can then choose their own compatibility fallback. + * 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) return null; + if (status !== 401 && status !== 403) return null; + if (body['statusCode'] !== status || typeof body['code'] !== 'string') return null; - return isAuthFailureScope(body['authFailureScope']) ? body['authFailureScope'] : null; + const scope = body['authFailureScope']; + if (!isAuthFailureScope(scope)) 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. */ 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 cc6f457..3127773 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. @@ -341,6 +342,59 @@ 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); + }); }); // ---- 401 handling --------------------------------------------------------- From d5ef5abe6f879f58ac1fd4591305f9d2cf2620c4 Mon Sep 17 00:00:00 2001 From: rdlabo Date: Sun, 26 Jul 2026 13:04:20 +0900 Subject: [PATCH 3/3] fix: unify identity cleanup across auth statuses --- projects/kit/README.md | 9 ++-- .../kit/src/lib/http/auth-failure.spec.ts | 14 ++++++ projects/kit/src/lib/http/auth-failure.ts | 1 + .../src/lib/http/kit-http.interceptor.spec.ts | 46 +++++++++++++++++++ .../kit/src/lib/http/kit-http.interceptor.ts | 17 ++++++- 5 files changed, 83 insertions(+), 4 deletions(-) diff --git a/projects/kit/README.md b/projects/kit/README.md index 8d94204..fd919b2 100644 --- a/projects/kit/README.md +++ b/projects/kit/README.md @@ -651,8 +651,10 @@ export const appConfig: ApplicationConfig = { getAuthHeaders: async (req) => ({ Authorization: `Bearer ${await auth.getToken()}`, }), + // Status-independent: handles both new identity 401 and tagged historical identity 403. + onAuthAccessDenial: () => auth.signOut(), onUnauthorized: (_req, error) => { - if (isIdentityAuthFailure(error)) auth.signOut(); + // Feature UX for reauthentication/credential failures may inspect the error here. }, // Fleet-canonical "network error → offer reload" (see KitReloadAlertController). onNetworkError: (status) => @@ -710,8 +712,9 @@ The shared `401` body carries `authFailureScope`: `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. `onUnauthorized` -receives the complete `HttpErrorResponse`, allowing destructive callbacks to use the same boundary. +`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. diff --git a/projects/kit/src/lib/http/auth-failure.spec.ts b/projects/kit/src/lib/http/auth-failure.spec.ts index e6c0c82..96b6328 100644 --- a/projects/kit/src/lib/http/auth-failure.spec.ts +++ b/projects/kit/src/lib/http/auth-failure.spec.ts @@ -65,4 +65,18 @@ describe('auth failure protocol', () => { ), ).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 index fb71037..7d96a32 100644 --- a/projects/kit/src/lib/http/auth-failure.ts +++ b/projects/kit/src/lib/http/auth-failure.ts @@ -44,6 +44,7 @@ export const getAuthFailureScope = (error: unknown): AuthFailureScope | 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; }; 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 3127773..3735297 100644 --- a/projects/kit/src/lib/http/kit-http.interceptor.spec.ts +++ b/projects/kit/src/lib/http/kit-http.interceptor.spec.ts @@ -395,6 +395,52 @@ describe('kitAuthInterceptor', () => { 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 --------------------------------------------------------- diff --git a/projects/kit/src/lib/http/kit-http.interceptor.ts b/projects/kit/src/lib/http/kit-http.interceptor.ts index 0165cb2..e89ee78 100644 --- a/projects/kit/src/lib/http/kit-http.interceptor.ts +++ b/projects/kit/src/lib/http/kit-http.interceptor.ts @@ -272,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. * @@ -441,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); @@ -492,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); }