Compare commits

..
4 changed files with 197 additions and 0 deletions
@@ -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<Request>,
* 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: () => {},
});
}
+5
View File
@@ -109,6 +109,11 @@ export function createHttpClient(dependencies) {
continue; continue;
} }
if (outcome.error.httpStatus === 401 && recoveryUsed) {
authSession.onUnauthenticated();
return outcome;
}
if (!shouldRetry(operation, outcome.error, retryCount)) { if (!shouldRetry(operation, outcome.error, retryCount)) {
return outcome; return outcome;
} }
+103
View File
@@ -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" },
});
});
});
+44
View File
@@ -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");
});
});