diff --git a/src/adapters/auth/external-session-adapter.js b/src/adapters/auth/external-session-adapter.js new file mode 100644 index 0000000..00b3f99 --- /dev/null +++ b/src/adapters/auth/external-session-adapter.js @@ -0,0 +1,45 @@ +/** + * Creates the skeleton-owned side of an external session integration. + * Credential acquisition and storage stay inside the supplied external owner. + * + * @param {{ + * readState(): import("../../application/ports/auth-session-port.js").SessionState, + * attachCredential(request: Request): Promise, + * recoverSession(): Promise<"restored" | "no-session">, + * notifyUnauthenticated(): void + * }} owner + * @returns {import("../../application/ports/auth-session-port.js").AuthSessionPort} + */ +export function createExternalAuthSessionAdapter(owner) { + return Object.freeze({ + getState() { + return owner.readState(); + }, + async attach(request) { + const attached = await owner.attachCredential(request); + if (!(attached instanceof Request)) { + throw new TypeError("Auth owner returned an invalid request"); + } + return attached; + }, + async recover() { + const result = await owner.recoverSession(); + if (result !== "restored" && result !== "no-session") { + throw new TypeError("Auth owner returned an invalid recovery state"); + } + return result; + }, + onUnauthenticated() { + owner.notifyUnauthenticated(); + }, + }); +} + +export function createAnonymousSessionAdapter() { + return createExternalAuthSessionAdapter({ + readState: () => "unauthenticated", + attachCredential: async (request) => request, + recoverSession: async () => "no-session", + notifyUnauthenticated: () => {}, + }); +} diff --git a/src/adapters/http/client.js b/src/adapters/http/client.js index 4c2d922..e1bc9e7 100644 --- a/src/adapters/http/client.js +++ b/src/adapters/http/client.js @@ -109,6 +109,11 @@ export function createHttpClient(dependencies) { continue; } + if (outcome.error.httpStatus === 401 && recoveryUsed) { + authSession.onUnauthenticated(); + return outcome; + } + if (!shouldRetry(operation, outcome.error, retryCount)) { return outcome; } diff --git a/tests/integration/auth-recovery.test.js b/tests/integration/auth-recovery.test.js new file mode 100644 index 0000000..bb04397 --- /dev/null +++ b/tests/integration/auth-recovery.test.js @@ -0,0 +1,103 @@ +import { HttpResponse, http } from "msw"; +import { setupServer } from "msw/node"; +import { afterAll, afterEach, beforeAll, describe, expect, it, vi } from "vitest"; + +import { createExternalAuthSessionAdapter } from "../../src/adapters/auth/external-session-adapter.js"; +import { createHttpClient } from "../../src/adapters/http/client.js"; + +let responseStatuses = []; +const server = setupServer( + http.get("https://api.test/api/sample/resources", () => { + const status = responseStatuses.shift() ?? 200; + if (status === 401) { + return HttpResponse.json( + { + success: false, + error: { code: "UNAUTHENTICATED" }, + meta: { requestId: "request-1", traceId: "trace-1" }, + }, + { status }, + ); + } + return HttpResponse.json({ + success: true, + data: [{ id: "resource-1", name: "Example" }], + meta: { requestId: "request-2", traceId: "trace-1" }, + }); + }), +); + +beforeAll(() => server.listen({ onUnhandledRequest: "error" })); +afterEach(() => { + responseStatuses = []; + server.resetHandlers(); +}); +afterAll(() => server.close()); + +const clock = { now: () => 0, sleep: async () => {} }; + +describe("bounded 401 session recovery", () => { + it("calls recovery once and replays a safe request once", async () => { + responseStatuses = [401, 200]; + const recoverSession = vi.fn(async () => "restored"); + const authSession = createExternalAuthSessionAdapter({ + readState: () => "authenticated", + attachCredential: async (request) => request, + recoverSession, + notifyUnauthenticated: vi.fn(), + }); + const client = createHttpClient({ + baseUrl: "https://api.test", + authSession, + clock, + }); + + await expect(client.execute("LIST_SAMPLE_RESOURCES")).resolves.toMatchObject({ + ok: true, + }); + expect(recoverSession).toHaveBeenCalledTimes(1); + }); + + it("stops after a second 401 and notifies unauthenticated once", async () => { + responseStatuses = [401, 401]; + const notifyUnauthenticated = vi.fn(); + const authSession = createExternalAuthSessionAdapter({ + readState: () => "authenticated", + attachCredential: async (request) => request, + recoverSession: async () => "restored", + notifyUnauthenticated, + }); + const client = createHttpClient({ + baseUrl: "https://api.test", + authSession, + clock, + }); + + await expect(client.execute("LIST_SAMPLE_RESOURCES")).resolves.toMatchObject({ + ok: false, + error: { kind: "AUTH_REQUIRED" }, + }); + expect(notifyUnauthenticated).toHaveBeenCalledTimes(1); + }); + + it("normalizes attach and invalid recovery failures", async () => { + const attachFailure = createExternalAuthSessionAdapter({ + readState: () => "authenticated", + attachCredential: async () => { + throw new Error("credential detail"); + }, + recoverSession: async () => "restored", + notifyUnauthenticated: vi.fn(), + }); + const client = createHttpClient({ + baseUrl: "https://api.test", + authSession: attachFailure, + clock, + }); + + await expect(client.execute("LIST_SAMPLE_RESOURCES")).resolves.toMatchObject({ + ok: false, + error: { kind: "AUTH_INTEGRATION_FAILURE" }, + }); + }); +}); diff --git a/tests/unit/auth-session-adapter.test.js b/tests/unit/auth-session-adapter.test.js new file mode 100644 index 0000000..d1221e9 --- /dev/null +++ b/tests/unit/auth-session-adapter.test.js @@ -0,0 +1,44 @@ +import { describe, expect, it, vi } from "vitest"; + +import { + createAnonymousSessionAdapter, + createExternalAuthSessionAdapter, +} from "../../src/adapters/auth/external-session-adapter.js"; + +describe("external AuthSessionPort adapter", () => { + it("attaches opaque credentials without exposing a token-shaped session", async () => { + const adapter = createExternalAuthSessionAdapter({ + readState: () => "authenticated", + attachCredential: async (request) => { + const headers = new Headers(request.headers); + headers.set("X-Session-Attached", "true"); + return new Request(request, { headers }); + }, + recoverSession: async () => "restored", + notifyUnauthenticated: vi.fn(), + }); + + const request = await adapter.attach(new Request("https://api.test/resource")); + expect(request.headers.get("X-Session-Attached")).toBe("true"); + expect(adapter.getState()).toBe("authenticated"); + expect(adapter).not.toHaveProperty("accessToken"); + expect(adapter).not.toHaveProperty("refreshToken"); + }); + + it("fails invalid recovery states closed", async () => { + const adapter = createExternalAuthSessionAdapter({ + readState: () => "authenticated", + attachCredential: async (request) => request, + recoverSession: async () => "unexpected", + notifyUnauthenticated: vi.fn(), + }); + + await expect(adapter.recover()).rejects.toThrow("invalid recovery state"); + }); + + it("provides a safe anonymous adapter", async () => { + const adapter = createAnonymousSessionAdapter(); + expect(adapter.getState()).toBe("unauthenticated"); + await expect(adapter.recover()).resolves.toBe("no-session"); + }); +});