diff --git a/README.md b/README.md index 4cdad28..58f586f 100644 --- a/README.md +++ b/README.md @@ -452,9 +452,9 @@ export class CatalogModule {} The `AuthStrategy` interface declares four methods that together own the full session lifecycle: - `authenticate(client: RestClient): Promise` — performs the handshake; the `client` argument is a fully resilient `RestClient`, so auth requests reuse the same resilience policy stack as application calls. -- `isAuthenticated(): boolean` — gates whether the next request needs to re-authenticate. +- `isAuthenticated(): Promise` — gates whether the next request needs to re-authenticate. - `extendRequest(config: AxiosRequestConfig): AxiosRequestConfig` — applies the credentials to a request config (must not mutate the input). -- `invalidate(): void` — drops the current session so the next request triggers a fresh handshake; called by `AuthRestClient` after a 401. +- `invalidate(): Promise` — drops the current session so the next request triggers a fresh handshake; called by `AuthRestClient` after a 401. ```ts import { @@ -489,7 +489,7 @@ class BearerTokenStrategy implements AuthStrategy { this.expiresAt = Date.now() + response.data.expires_in * 1_000 - 60_000 } - isAuthenticated(): boolean { + async isAuthenticated(): Promise { return this.token !== undefined && Date.now() < this.expiresAt } @@ -500,7 +500,7 @@ class BearerTokenStrategy implements AuthStrategy { } } - invalidate(): void { + async invalidate(): Promise { this.token = undefined this.expiresAt = 0 } @@ -654,10 +654,10 @@ new AuthProcessor(strategy: AuthStrategy, client: RestClient) Public methods: -- `isAuthenticated(): boolean` — pure delegation to `strategy.isAuthenticated()`. The processor caches nothing; the strategy is the single source of truth. -- `authenticateIfNeeded(): Promise` — short-circuits when `isAuthenticated()` returns `true`; otherwise triggers a fresh `strategy.authenticate(client)` handshake. Concurrent callers share a single in-flight handshake (single-flight via `@DeduplicateInflight`). +- `isAuthenticated(): Promise` — awaited delegation to `strategy.isAuthenticated()`. The processor caches nothing; the strategy is the single source of truth. +- `authenticateIfNeeded(): Promise` — short-circuits when `await isAuthenticated()` resolves with `true`; otherwise triggers a fresh `strategy.authenticate(client)` handshake. Concurrent callers share a single in-flight handshake (single-flight via `@DeduplicateInflight`). - `extendRequest(config: AxiosRequestConfig): AxiosRequestConfig` — pure delegation to `strategy.extendRequest(config)`. The strategy contract forbids mutating the input. -- `clearAuth(): void` — pure delegation to `strategy.invalidate()`. After this call, `isAuthenticated()` returns `false` and the next `authenticateIfNeeded()` triggers a fresh handshake. Used by `AuthRestClient`'s 401 retry path. +- `clearAuth(): Promise` — pure delegation to `strategy.invalidate()`, `await isAuthenticated()` resolves with `false` and the next `authenticateIfNeeded()` triggers a fresh handshake. Used by `AuthRestClient`'s 401 retry path. #### `AuthRestModule` @@ -748,11 +748,13 @@ interface AuthStrategy { authenticate(client: RestClient): Promise /** - * Returns `true` while the current credentials are still considered valid. - * Must return `false` when no session has been established yet or after - * `invalidate()` has been called. + * Resolves with `true` while the current credentials are still considered + * valid. Must resolve with `false` when no session has been established yet + * or after `invalidate()` has been called. Asynchronous so implementations + * can consult persisted credential stores or remote token-introspection + * endpoints without blocking the dispatch path on synchronous I/O. */ - isAuthenticated(): boolean + isAuthenticated(): Promise /** * Returns a NEW `AxiosRequestConfig` with authentication material applied. @@ -762,10 +764,12 @@ interface AuthStrategy { /** * Drops the current session so the next request triggers a fresh - * `authenticate()` call. Invoked by `AuthRestClient` after the upstream + * `authenticate()` call. Resolves once the session has been dropped, so + * implementations may flush persisted credentials or await a remote + * sign-out endpoint. Invoked by `AuthRestClient` after the upstream * service rejects a request with HTTP 401. */ - invalidate(): void + invalidate(): Promise } ``` diff --git a/src/auth/__tests__/auth-processor.spec.ts b/src/auth/__tests__/auth-processor.spec.ts index 5ff0213..23ffb5a 100644 --- a/src/auth/__tests__/auth-processor.spec.ts +++ b/src/auth/__tests__/auth-processor.spec.ts @@ -19,9 +19,9 @@ const delay = (ms: number): Promise => */ interface AuthStrategyStub extends AuthStrategy { authenticate: jest.Mock, [RestClient]>; - isAuthenticated: jest.Mock; + isAuthenticated: jest.Mock, []>; extendRequest: jest.Mock; - invalidate: jest.Mock; + invalidate: jest.Mock, []>; } /** @@ -33,7 +33,7 @@ interface AuthStrategyStub extends AuthStrategy { function createStrategyStub(): AuthStrategyStub { return { authenticate: jest.fn().mockResolvedValue(undefined), - isAuthenticated: jest.fn().mockReturnValue(true), + isAuthenticated: jest.fn().mockResolvedValue(true), extendRequest: jest.fn( (config: AxiosRequestConfig): AxiosRequestConfig => ({ ...config, @@ -43,7 +43,7 @@ function createStrategyStub(): AuthStrategyStub { }, }), ), - invalidate: jest.fn(), + invalidate: jest.fn().mockResolvedValue(undefined), }; } @@ -56,29 +56,63 @@ const fakeClient = { iAm: "the-rest-client" } as unknown as RestClient; describe("AuthProcessor", () => { describe("isAuthenticated()", () => { - it("returns true when the strategy reports authenticated", () => { + it("resolves with true when the strategy reports authenticated", async () => { const strategy = createStrategyStub(); - strategy.isAuthenticated.mockReturnValue(true); + strategy.isAuthenticated.mockResolvedValue(true); const processor = new AuthProcessor(strategy, fakeClient); - expect(processor.isAuthenticated()).toBe(true); + await expect(processor.isAuthenticated()).resolves.toBe(true); expect(strategy.isAuthenticated).toHaveBeenCalledTimes(1); }); - it("returns false when the strategy reports unauthenticated", () => { + it("resolves with false when the strategy reports unauthenticated", async () => { const strategy = createStrategyStub(); - strategy.isAuthenticated.mockReturnValue(false); + strategy.isAuthenticated.mockResolvedValue(false); const processor = new AuthProcessor(strategy, fakeClient); - expect(processor.isAuthenticated()).toBe(false); + await expect(processor.isAuthenticated()).resolves.toBe(false); expect(strategy.isAuthenticated).toHaveBeenCalledTimes(1); }); + + it("awaits the strategy's async isAuthenticated before resolving (deferred Promise ordering)", async () => { + // Pins the awaited-delegation invariant: `AuthProcessor.isAuthenticated` + // MUST `await this.strategy.isAuthenticated()` rather than returning the + // raw Promise unchanged or eagerly resolving. A regression that dropped + // the await would still type-check (the method would simply return + // `Promise>` flattened by the runtime). This test gates + // the ordering with a deferred Promise that only resolves when the test + // explicitly releases it. + const strategy = createStrategyStub(); + let resolveIsAuth!: (value: boolean) => void; + const deferred = new Promise((resolve) => { + resolveIsAuth = resolve; + }); + strategy.isAuthenticated.mockReturnValueOnce(deferred); + const processor = new AuthProcessor(strategy, fakeClient); + + let settled = false; + const checkPromise = processor.isAuthenticated().then((value) => { + settled = true; + return value; + }); + + // Drain microtasks/macrotasks — the inner Promise is still pending, so + // the outer Promise must NOT have settled yet. A regression that + // bypassed the await (e.g., returning a stale cached value) would have + // resolved before the test released the deferred. + await new Promise((resolve) => setImmediate(resolve)); + expect(settled).toBe(false); + + resolveIsAuth(true); + await expect(checkPromise).resolves.toBe(true); + expect(settled).toBe(true); + }); }); describe("authenticateIfNeeded()", () => { - it("skips strategy.authenticate when strategy.isAuthenticated() returns true", async () => { + it("skips strategy.authenticate when strategy.isAuthenticated() resolves with true", async () => { const strategy = createStrategyStub(); - strategy.isAuthenticated.mockReturnValue(true); + strategy.isAuthenticated.mockResolvedValue(true); const processor = new AuthProcessor(strategy, fakeClient); await processor.authenticateIfNeeded(); @@ -86,9 +120,9 @@ describe("AuthProcessor", () => { expect(strategy.authenticate).not.toHaveBeenCalled(); }); - it("calls strategy.authenticate when strategy.isAuthenticated() returns false", async () => { + it("calls strategy.authenticate when strategy.isAuthenticated() resolves with false", async () => { const strategy = createStrategyStub(); - strategy.isAuthenticated.mockReturnValue(false); + strategy.isAuthenticated.mockResolvedValue(false); const processor = new AuthProcessor(strategy, fakeClient); await processor.authenticateIfNeeded(); @@ -98,7 +132,7 @@ describe("AuthProcessor", () => { it("forwards the configured client to strategy.authenticate(client)", async () => { const strategy = createStrategyStub(); - strategy.isAuthenticated.mockReturnValue(false); + strategy.isAuthenticated.mockResolvedValue(false); const processor = new AuthProcessor(strategy, fakeClient); await processor.authenticateIfNeeded(); @@ -108,7 +142,7 @@ describe("AuthProcessor", () => { it("coalesces concurrent first-time callers into exactly ONE strategy.authenticate invocation (single-flight)", async () => { const strategy = createStrategyStub(); - strategy.isAuthenticated.mockReturnValue(false); + strategy.isAuthenticated.mockResolvedValue(false); strategy.authenticate.mockImplementation(async () => { // 50ms delay guarantees that all concurrent callers land while // the first call is still in flight, so @DeduplicateInflight must @@ -132,7 +166,7 @@ describe("AuthProcessor", () => { it("clears the inflight entry after the awaited promise resolves", async () => { const strategy = createStrategyStub(); - strategy.isAuthenticated.mockReturnValue(false); + strategy.isAuthenticated.mockResolvedValue(false); const processor = new AuthProcessor(strategy, fakeClient); await processor.authenticateIfNeeded(); @@ -170,11 +204,11 @@ describe("AuthProcessor", () => { }); describe("clearAuth()", () => { - it("calls strategy.invalidate() exactly once", () => { + it("calls strategy.invalidate() exactly once", async () => { const strategy = createStrategyStub(); const processor = new AuthProcessor(strategy, fakeClient); - processor.clearAuth(); + await processor.clearAuth(); expect(strategy.invalidate).toHaveBeenCalledTimes(1); }); @@ -184,8 +218,8 @@ describe("AuthProcessor", () => { // Model a strategy whose `invalidate()` flips `isAuthenticated()` to false, // mirroring the contract documented on AuthStrategy.invalidate. let authenticated = true; - strategy.isAuthenticated.mockImplementation(() => authenticated); - strategy.invalidate.mockImplementation(() => { + strategy.isAuthenticated.mockImplementation(async () => authenticated); + strategy.invalidate.mockImplementation(async () => { authenticated = false; }); strategy.authenticate.mockImplementation(async () => { @@ -198,15 +232,15 @@ describe("AuthProcessor", () => { expect(strategy.authenticate).not.toHaveBeenCalled(); // Invalidate the session — strategy now reports unauthenticated. - processor.clearAuth(); + await processor.clearAuth(); expect(strategy.invalidate).toHaveBeenCalledTimes(1); - expect(processor.isAuthenticated()).toBe(false); + await expect(processor.isAuthenticated()).resolves.toBe(false); // Next pre-flight must trigger a fresh handshake forwarded with the client. await processor.authenticateIfNeeded(); expect(strategy.authenticate).toHaveBeenCalledTimes(1); expect(strategy.authenticate).toHaveBeenCalledWith(fakeClient); - expect(processor.isAuthenticated()).toBe(true); + await expect(processor.isAuthenticated()).resolves.toBe(true); }); }); }); diff --git a/src/auth/__tests__/auth-rest.client.spec.ts b/src/auth/__tests__/auth-rest.client.spec.ts index 3a0a4ff..eaade6c 100644 --- a/src/auth/__tests__/auth-rest.client.spec.ts +++ b/src/auth/__tests__/auth-rest.client.spec.ts @@ -68,7 +68,7 @@ function createRestClientStub(response: AxiosResponse = { data: 'ok' } as AxiosR interface AuthStrategyStub { authenticateIfNeeded: jest.Mock, []> extendRequest: jest.Mock - clearAuth: jest.Mock + clearAuth: jest.Mock, []> } /** @@ -87,7 +87,7 @@ function createAuthStrategyStub(): AuthStrategyStub { }, }), ), - clearAuth: jest.fn(), + clearAuth: jest.fn().mockResolvedValue(undefined), } } @@ -216,6 +216,60 @@ describe('AuthRestClient', () => { expect(authStrategy.authenticateIfNeeded).toHaveBeenCalledTimes(2) expect(authStrategy.clearAuth).toHaveBeenCalledTimes(1) }) + + it('awaits clearAuth before invoking authenticateIfNeeded on the 401 recovery path', async () => { + // Pins the awaited-ordering invariant on the 401 recovery branch: + // `AuthRestClient.dispatch` MUST `await processor.clearAuth()` BEFORE + // calling `authenticateIfNeeded` for the re-handshake. A regression that + // drops the `await` would still type-check (both return Promise) + // and could pass call-count assertions because nothing else pins the + // resolution timing — so this test gates the ordering with a deferred + // Promise that only resolves when the test explicitly releases it. + const { client, restClient, authStrategy } = buildSut() + + restClient.get + .mockRejectedValueOnce(makeAxiosError(401)) + .mockResolvedValueOnce({ data: 'ok' } as AxiosResponse) + + // Manually-resolved deferred so `clearAuth` stays pending until the + // test releases it. The pre-flight authenticateIfNeeded fires once + // BEFORE the 401, so we snapshot that baseline call count and assert + // the recovery handshake has not yet been triggered. + let resolveClearAuth!: () => void + const clearAuthDeferred = new Promise((resolve) => { + resolveClearAuth = resolve + }) + authStrategy.clearAuth.mockImplementationOnce(() => clearAuthDeferred) + + const dispatchPromise = client.get('/x') + + // Drain the microtask + macrotask queues so the dispatcher reaches + // `clearAuth` and suspends on the deferred. A `setImmediate` boundary + // is required because the pre-flight `authenticateIfNeeded` await, the + // first `restClient.get` rejection, and the error-branch `await` form + // a chain of microtasks that all need to flush before `clearAuth` is + // actually invoked. + await new Promise(resolve => setImmediate(resolve)) + + // Pre-flight handshake ran once; recovery handshake MUST NOT have + // fired while `clearAuth` is still pending. A dropped `await` on + // `clearAuth` would have allowed authenticateIfNeeded to run already. + expect(authStrategy.clearAuth).toHaveBeenCalledTimes(1) + expect(authStrategy.authenticateIfNeeded).toHaveBeenCalledTimes(1) + + // Release the deferred — the dispatcher should now proceed to the + // recovery handshake and the second underlying RestClient call. + resolveClearAuth() + + const response = await dispatchPromise + + expect(response.data).toBe('ok') + // Recovery handshake fired exactly once AFTER clearAuth resolved, + // bringing the total authenticateIfNeeded invocations to two. + expect(authStrategy.authenticateIfNeeded).toHaveBeenCalledTimes(2) + expect(authStrategy.clearAuth).toHaveBeenCalledTimes(1) + expect(restClient.get).toHaveBeenCalledTimes(2) + }) }) describe('500 error path', () => { diff --git a/src/auth/__tests__/auth-rest.module.spec.ts b/src/auth/__tests__/auth-rest.module.spec.ts index afc9860..900c4ce 100644 --- a/src/auth/__tests__/auth-rest.module.spec.ts +++ b/src/auth/__tests__/auth-rest.module.spec.ts @@ -68,9 +68,9 @@ class StrategyWithDeps implements AuthStrategy { constructor(@Inject(SENTINEL_TOKEN) public readonly sentinel: string) {} async authenticate(_client: RestClient): Promise {} - isAuthenticated(): boolean { return true } + async isAuthenticated(): Promise { return true } extendRequest(config: AxiosRequestConfig): AxiosRequestConfig { return config } - invalidate(): void {} + async invalidate(): Promise {} } // Wraps the sentinel in its own NestJS module so it can be imported into @@ -123,7 +123,7 @@ class StubAuthStrategy implements AuthStrategy { // no-op; the module spec does not exercise auth flows } - isAuthenticated(): boolean { + async isAuthenticated(): Promise { return true } @@ -131,7 +131,7 @@ class StubAuthStrategy implements AuthStrategy { return config } - invalidate(): void { + async invalidate(): Promise { // no-op; the module spec does not exercise invalidation } } @@ -147,7 +147,7 @@ class StubAuthStrategy implements AuthStrategy { class StampingAuthStrategy implements AuthStrategy { async authenticate(_client: RestClient): Promise {} - isAuthenticated(): boolean { + async isAuthenticated(): Promise { return true } @@ -158,7 +158,7 @@ class StampingAuthStrategy implements AuthStrategy { } } - invalidate(): void {} + async invalidate(): Promise {} } const successResponse: AxiosResponse = { diff --git a/src/auth/auth-processor.ts b/src/auth/auth-processor.ts index e8bfaac..77fd3be 100644 --- a/src/auth/auth-processor.ts +++ b/src/auth/auth-processor.ts @@ -65,7 +65,7 @@ const AUTHENTICATE_DEDUP_KEY = "authenticate"; * const extended = processor.extendRequest({ url: '/orders' }) * // -> { url: '/orders', headers: { Authorization: 'Bearer ...' } } * - * processor.clearAuth() // -> strategy.invalidate(); next call re-authenticates + * await processor.clearAuth() // -> strategy.invalidate(); next call re-authenticates * ``` */ export class AuthProcessor { @@ -120,34 +120,37 @@ export class AuthProcessor { ) {} /** - * Returns whatever the injected strategy reports about its current + * Resolves with whatever the injected strategy reports about its current * session validity. The processor caches nothing of its own — the - * strategy is the single source of truth. + * strategy is the single source of truth. Asynchronous because + * {@link AuthStrategy.isAuthenticated} is asynchronous (implementations + * may consult persisted credential stores or remote introspection + * endpoints). * * @example * ```ts * const processor = new AuthProcessor(strategy, restClient) * - * if (!processor.isAuthenticated()) { + * if (!(await processor.isAuthenticated())) { * await processor.authenticateIfNeeded() * } - * // -> true once a successful handshake has completed + * // -> resolves to true once a successful handshake has completed * ``` */ - isAuthenticated(): boolean { - return this.strategy.isAuthenticated(); + async isAuthenticated(): Promise { + return await this.strategy.isAuthenticated(); } /** * Ensures a valid authentication session exists. Short-circuits when - * {@link isAuthenticated} returns `true`; otherwise delegates to the + * {@link isAuthenticated} resolves with `true`; otherwise delegates to the * deduplicated {@link performAuthenticate} so concurrent callers share * a single underlying handshake. * * Race-safety note: even when two concurrent callers both observe - * `isAuthenticated() === false` and proceed to `performAuthenticate()`, - * `@DeduplicateInflight` collapses them into one `strategy.authenticate` - * invocation (single-flight invariant). + * `await isAuthenticated() === false` and proceed to + * `performAuthenticate()`, `@DeduplicateInflight` collapses them into one + * `strategy.authenticate` invocation (single-flight invariant). * * @example * ```ts @@ -163,7 +166,7 @@ export class AuthProcessor { * ``` */ async authenticateIfNeeded(): Promise { - if (this.isAuthenticated()) { + if (await this.isAuthenticated()) { return; } @@ -191,24 +194,27 @@ export class AuthProcessor { /** * Invalidates the strategy's session by delegating to - * {@link AuthStrategy.invalidate}. After this call, - * {@link isAuthenticated} returns `false` and the next - * {@link authenticateIfNeeded} triggers a fresh handshake. Used by - * `AuthRestClient`'s 401 retry path. + * {@link AuthStrategy.invalidate}. Resolves once the strategy has + * finished dropping its session state; awaiting is required because + * {@link AuthStrategy.invalidate} is asynchronous (implementations may + * flush persisted credentials, hit a remote sign-out endpoint, etc.). + * After the returned promise resolves, {@link isAuthenticated} resolves + * with `false` and the next {@link authenticateIfNeeded} triggers a fresh + * handshake. Used by `AuthRestClient`'s 401 retry path. * * @example * ```ts * const processor = new AuthProcessor(strategy, restClient) * await processor.authenticateIfNeeded() - * // processor.isAuthenticated() === true + * // await processor.isAuthenticated() === true * - * processor.clearAuth() - * // processor.isAuthenticated() === false + * await processor.clearAuth() + * // await processor.isAuthenticated() === false * // Next authenticateIfNeeded() call triggers a fresh handshake. * ``` */ - clearAuth(): void { - this.strategy.invalidate(); + async clearAuth(): Promise { + await this.strategy.invalidate(); } /** diff --git a/src/auth/auth-rest.client.ts b/src/auth/auth-rest.client.ts index 703ef33..1bc6f12 100644 --- a/src/auth/auth-rest.client.ts +++ b/src/auth/auth-rest.client.ts @@ -172,7 +172,7 @@ export class AuthRestClient extends HookableHttpService { throw error } - this.processor.clearAuth() + await this.processor.clearAuth() await this.processor.authenticateIfNeeded() const retryArgs: InvokeArgs = { ...initialArgs, diff --git a/src/auth/auth-rest.module.ts b/src/auth/auth-rest.module.ts index 8fc1c9a..151696e 100644 --- a/src/auth/auth-rest.module.ts +++ b/src/auth/auth-rest.module.ts @@ -156,7 +156,7 @@ export const AUTH_MODULE_OPTIONS: unique symbol = Symbol('AUTH_MODULE_OPTIONS') * this.expiresAt = Date.now() + response.data.expires_in * 1_000 - 60_000 * } * - * isAuthenticated(): boolean { + * async isAuthenticated(): Promise { * return this.token !== undefined && Date.now() < this.expiresAt * } * @@ -167,7 +167,7 @@ export const AUTH_MODULE_OPTIONS: unique symbol = Symbol('AUTH_MODULE_OPTIONS') * } * } * - * invalidate(): void { + * async invalidate(): Promise { * this.token = undefined * this.expiresAt = 0 * } @@ -245,11 +245,11 @@ export class AuthRestModule { * @Injectable() * class StaticBearerStrategy implements AuthStrategy { * async authenticate() {} - * isAuthenticated() { return true } + * async isAuthenticated() { return true } * extendRequest(config) { * return { ...config, headers: { ...config.headers, Authorization: 'Bearer static' } } * } - * invalidate() {} + * async invalidate() {} * } * * AuthRestModule.registerAsync({ diff --git a/src/auth/auth.config.ts b/src/auth/auth.config.ts index aacbd0c..f442ff9 100644 --- a/src/auth/auth.config.ts +++ b/src/auth/auth.config.ts @@ -51,7 +51,7 @@ import type { RestClient } from "../client/rest.client"; * this.expiresAt = Date.now() + response.data.expires_in * 1_000 - 60_000 * } * - * isAuthenticated(): boolean { + * async isAuthenticated(): Promise { * return this.token !== undefined && Date.now() < this.expiresAt * } * @@ -62,7 +62,7 @@ import type { RestClient } from "../client/rest.client"; * } * } * - * invalidate(): void { + * async invalidate(): Promise { * this.token = undefined * this.expiresAt = 0 * } @@ -86,13 +86,17 @@ export interface AuthStrategy { authenticate(client: RestClient): Promise; /** - * Returns `true` while the current credentials are still considered valid. - * Implementations typically compare a token expiry timestamp against the - * current time, optionally with a safety margin. Must return `false` when - * no session has been established yet or after {@link invalidate} has been - * called. + * Resolves with `true` while the current credentials are still considered + * valid. Implementations typically compare a token expiry timestamp + * against the current time, optionally with a safety margin. Must resolve + * with `false` when no session has been established yet or after + * {@link invalidate} has been called. + * + * Asynchronous so implementations can consult persisted credential stores + * (filesystem, keychain, remote token-introspection endpoints, etc.) + * without blocking the dispatch path on synchronous I/O. */ - isAuthenticated(): boolean; + isAuthenticated(): Promise; /** * Returns a new {@link AxiosRequestConfig} with authentication material @@ -103,10 +107,13 @@ export interface AuthStrategy { /** * Drops the current session so that the next request triggers a fresh - * {@link authenticate} call. After this method returns, {@link isAuthenticated} - * must report `false` until a successful handshake re-establishes the - * session. Typically invoked by the consuming infrastructure after the - * upstream service rejects a request with HTTP 401. + * {@link authenticate} call. Resolves once the session has been dropped + * (asynchronous so implementations can flush persisted credentials, await + * remote sign-out endpoints, etc.). After the returned promise resolves, + * {@link isAuthenticated} must report `false` until a successful handshake + * re-establishes the session. Typically invoked by the consuming + * infrastructure after the upstream service rejects a request with HTTP + * 401. */ - invalidate(): void; + invalidate(): Promise; } diff --git a/tests/auth-rest-client.e2e.spec.ts b/tests/auth-rest-client.e2e.spec.ts index 90df11c..588fbf8 100644 --- a/tests/auth-rest-client.e2e.spec.ts +++ b/tests/auth-rest-client.e2e.spec.ts @@ -82,7 +82,7 @@ class CountingAuthStrategy implements AuthStrategy { this.authenticated = true } - isAuthenticated(): boolean { + async isAuthenticated(): Promise { return this.authenticated } @@ -96,7 +96,7 @@ class CountingAuthStrategy implements AuthStrategy { } } - invalidate(): void { + async invalidate(): Promise { this.authenticated = false } } @@ -113,7 +113,7 @@ class CountingAuthStrategy implements AuthStrategy { class StampingAuthStrategy implements AuthStrategy { async authenticate(_client: RestClient): Promise {} - isAuthenticated(): boolean { + async isAuthenticated(): Promise { return true } @@ -127,7 +127,7 @@ class StampingAuthStrategy implements AuthStrategy { } } - invalidate(): void {} + async invalidate(): Promise {} } /**