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
44 changes: 39 additions & 5 deletions projects/kit/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
Expand All @@ -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({
Expand Down Expand Up @@ -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
Expand Down
82 changes: 82 additions & 0 deletions projects/kit/src/lib/http/auth-failure.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
53 changes: 53 additions & 0 deletions projects/kit/src/lib/http/auth-failure.ts
Original file line number Diff line number Diff line change
@@ -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];

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 公開型にドキュメントコメントが付いていない

公開APIとして公開される型 AuthFailureScopeprojects/kit/src/lib/http/auth-failure.ts:13)にJSDocコメントが付いていないため、リポジトリの必須ルールに違反しています。
Impact: ライブラリ利用者向けのAPIドキュメントが欠落します。

リポジトリ規約違反の内訳

AGENTS.mdの「Every public class, function, and type must have a JSDoc comment.」および同一リポジトリの既存慣習(projects/kit/src/lib/auth/auth-access.service.ts:5-6KitAuthAccessMode などは全てJSDoc付き)に反し、export type AuthFailureScope にJSDocがありません。public-api.ts 経由で公開されます。

Suggested change
export type AuthFailureScope = (typeof AUTH_FAILURE_SCOPES)[keyof typeof AUTH_FAILURE_SCOPES];
/** Lifecycle scope of an authentication failure, parsed from the response body. */
export type AuthFailureScope = (typeof AUTH_FAILURE_SCOPES)[keyof typeof AUTH_FAILURE_SCOPES];
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.


export const AUTH_IDENTITY_INVALID_CODE = 'AUTH_IDENTITY_INVALID';

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 公開定数にドキュメントコメントが付いていない

公開APIとして公開される定数 AUTH_IDENTITY_INVALID_CODEprojects/kit/src/lib/http/auth-failure.ts:15)にJSDocコメントが付いていないため、リポジトリの必須ルールおよび既存慣習に違反しています。
Impact: ライブラリ利用者向けのAPIドキュメントが欠落します。

リポジトリ規約違反の内訳

同ファイルの AUTH_FAILURE_SCOPESprojects/kit/src/lib/http/auth-failure.ts:7)や auth-access.service.ts:57-58 の公開定数はいずれもJSDoc付きであり、公開定数にドキュメントを付ける慣習が確立しています。AUTH_IDENTITY_INVALID_CODE のみ欠落しています。

Suggested change
export const AUTH_IDENTITY_INVALID_CODE = 'AUTH_IDENTITY_INVALID';
/** Body `code` that must accompany an `identity` scope to revoke global access. */
export const AUTH_IDENTITY_INVALID_CODE = 'AUTH_IDENTITY_INVALID';
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.


export interface AuthFailureBody {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 公開インターフェースにドキュメントコメントが付いていない

公開APIとして公開されるインターフェース AuthFailureBodyprojects/kit/src/lib/http/auth-failure.ts:17)にJSDocコメントが付いていないため、リポジトリの必須ルールに違反しています。
Impact: ライブラリ利用者向けのAPIドキュメントが欠落します。

リポジトリ規約違反の内訳

AGENTS.mdの「Every public class, function, and type must have a JSDoc comment.」に反し、export interface AuthFailureBody にJSDocがありません。auth-access.service.ts:8-9,40-41 の公開インターフェースは全てJSDoc付きです。

Suggested change
export interface AuthFailureBody {
/** Shape of an authentication failure response body carrying an explicit scope. */
export interface AuthFailureBody {
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

statusCode: 401 | 403;
message: string;
code: string;
authFailureScope: AuthFailureScope;
}

type UnknownRecord = Record<string, unknown>;

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;
108 changes: 104 additions & 4 deletions projects/kit/src/lib/http/kit-http.interceptor.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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);
Expand All @@ -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);
});

Expand All @@ -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 ---------------------------------------------------------
Expand All @@ -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();
});
});
Expand All @@ -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();
});
});
Expand Down
Loading