import type { Did } from "@atcute/lexicons/syntax";
import type { Session } from "@atcute/oauth-browser-client";
import {
LoginError,
OAuthResponseError,
OAuthUserAgent,
TokenRefreshError,
createAuthorizationUrl,
deleteStoredSession,
finalizeAuthorization,
getSession
} from "@atcute/oauth-browser-client";
import { type Mock, beforeEach, describe, expect, it, vi } from "vitest";
import { createAuth, refreshSessionCandidates, resumeSignIn } from "#lib/auth.svelte.ts";
import { consumePendingCallback } from "#lib/auth/pending.ts";
interface MockAgent {
sub: string;
getSession: Mock;
}
vi.mock("@atcute/oauth-browser-client", async (importOriginal) => {
const mod = await importOriginal<Record<string, unknown>>();
class MockOAuthUserAgent {
static instances: MockOAuthUserAgent[] = [];
readonly sub: string;
getSession = vi.fn(async () => this.session);
signOut = vi.fn(async () => {});
constructor(readonly session: { info: { sub: string } }) {
this.sub = session.info.sub;
MockOAuthUserAgent.instances.push(this);
}
}
return {
...mod,
OAuthUserAgent: MockOAuthUserAgent,
configureOAuth: vi.fn(),
createAuthorizationUrl: vi.fn(),
finalizeAuthorization: vi.fn(),
getSession: vi.fn(),
listStoredSessions: vi.fn(() => []),
deleteStoredSession: vi.fn()
};
});
vi.mock("#lib/auth/pending.ts", () => ({
consumePendingCallback: vi.fn(),
writePendingLogin: vi.fn()
}));
// the vi.mock above swaps in a class that records its instances
const MockedUserAgent = OAuthUserAgent as unknown as { instances: MockAgent[] };
const mockedGetSession = vi.mocked(getSession);
const mockedDeleteStoredSession = vi.mocked(deleteStoredSession);
const mockedFinalize = vi.mocked(finalizeAuthorization);
const mockedConsumePending = vi.mocked(consumePendingCallback);
const mockedCreateAuthorizationUrl = vi.mocked(createAuthorizationUrl);
const alice = "did:plc:alice" as Did;
const liveSession = (did: Did): Session => ({ info: { sub: did } }) as unknown as Session;
beforeEach(() => {
vi.clearAllMocks();
MockedUserAgent.instances.length = 0;
});
describe("refreshSessionCandidates", () => {
it("excludes delegated organization sessions even when one was active", () => {
const org = "did:plc:org" as Did;
const bob = "did:plc:bob" as Did;
expect(refreshSessionCandidates(org, org, [org, alice, bob], [org])).toEqual([alice, bob]);
});
it("deduplicates personal session candidates in priority order", () => {
const bob = "did:plc:bob" as Did;
expect(refreshSessionCandidates(alice, bob, [bob, alice], [])).toEqual([alice, bob]);
});
});
describe("agentFor", () => {
it("returns a live agent and reuses it", async () => {
mockedGetSession.mockResolvedValue(liveSession(alice));
const auth = createAuth("http://127.0.0.1:1", null);
const agent = await auth.agentFor(alice);
expect(agent.sub).toBe(alice);
expect(await auth.agentFor(alice)).toBe(agent);
expect(mockedGetSession).toHaveBeenCalledTimes(1);
expect(mockedGetSession.mock.calls[0]).toEqual([alice]);
expect(MockedUserAgent.instances[0].getSession).toHaveBeenCalledTimes(1);
});
it("prunes a revoked session and throws a friendly error", async () => {
mockedGetSession.mockRejectedValue(new TokenRefreshError(alice, "session was revoked"));
const auth = createAuth("http://127.0.0.1:1", null);
await expect(auth.agentFor(alice)).rejects.toThrow(/session expired/);
expect(mockedDeleteStoredSession).toHaveBeenCalledWith(alice);
});
it("treats an invalid_token response from the token endpoint as dead", async () => {
mockedGetSession.mockRejectedValue(
new OAuthResponseError(new Response(null, { status: 400 }), {
error: "invalid_token",
error_description: '"exp" claim timestamp check failed'
})
);
const auth = createAuth("http://127.0.0.1:1", null);
await expect(auth.agentFor(alice)).rejects.toThrow(/session expired/);
expect(mockedDeleteStoredSession).toHaveBeenCalledWith(alice);
});
it("keeps the session on transient failures", async () => {
const cause = new TypeError("fetch failed");
mockedGetSession.mockRejectedValue(cause);
const auth = createAuth("http://127.0.0.1:1", null);
await expect(auth.agentFor(alice)).rejects.toBe(cause);
expect(mockedDeleteStoredSession).not.toHaveBeenCalled();
});
it("prunes a cached agent whose session died since it was minted", async () => {
mockedGetSession.mockResolvedValue(liveSession(alice));
const auth = createAuth("http://127.0.0.1:1", null);
await auth.agentFor(alice);
MockedUserAgent.instances[0].getSession.mockRejectedValue(
new TokenRefreshError(alice, "session was revoked")
);
await expect(auth.agentFor(alice)).rejects.toThrow(/session expired/);
expect(mockedDeleteStoredSession).toHaveBeenCalledWith(alice);
});
});
describe("completePendingSignIn", () => {
const seeded = { did: alice, handle: "alice.example" };
it("adopts the exchanged session without disturbing the seeded profile", async () => {
mockedFinalize.mockResolvedValue({ session: liveSession(alice), state: null });
const auth = createAuth("http://127.0.0.1:1", seeded);
const completion = auth.completePendingSignIn("#state=sid&code=c&iss=https://pds.example");
// the topbar must not drop to the skeleton mid-exchange
expect(auth.resolving).toBe(false);
expect(auth.currentUser).toEqual({ did: alice, handle: "alice.example" });
await completion;
expect(auth.currentUser).toEqual({ did: alice, handle: "alice.example" });
expect(mockedFinalize).toHaveBeenCalledTimes(1);
expect(mockedFinalize.mock.calls[0][0].get("code")).toBe("c");
});
it("drops the seeded account and rethrows when the exchange fails", async () => {
mockedFinalize.mockRejectedValue(new LoginError("unknown state provided"));
const auth = createAuth("http://127.0.0.1:1", seeded);
await expect(auth.completePendingSignIn("#state=sid&code=c")).rejects.toThrow(
/unknown state/
);
expect(auth.state.kind).toBe("logged-out");
expect(auth.currentUser).toBeNull();
});
it("blocks agentFor until the pending exchange has stored the session", async () => {
const gate = Promise.withResolvers<{ session: Session; state: null }>();
mockedFinalize.mockReturnValue(gate.promise);
mockedGetSession.mockResolvedValue(liveSession(alice));
const auth = createAuth("http://127.0.0.1:1", seeded);
const completion = auth.completePendingSignIn("#state=sid&code=c");
const agentPromise = auth.agentFor(alice);
// flush microtasks so agentFor reaches its park point on the exchange
for (let i = 0; i < 5; i++) await Promise.resolve();
expect(mockedGetSession).not.toHaveBeenCalled();
gate.resolve({ session: liveSession(alice), state: null });
await completion;
// the adopted session satisfies agentFor without a storage reload
expect((await agentPromise).sub).toBe(alice);
expect(mockedGetSession).not.toHaveBeenCalled();
});
});
describe("startAuthorization", () => {
it("pushes a request at the pds and hands back its request_uri", async () => {
mockedCreateAuthorizationUrl.mockResolvedValue(
new URL("https://pds.example/oauth/authorize?client_id=c&request_uri=urn%3Areq")
);
const auth = createAuth("http://127.0.0.1:1", null);
await expect(auth.startAuthorization("https://pds.example", "/welcome")).resolves.toBe(
"urn:req"
);
expect(mockedCreateAuthorizationUrl.mock.calls[0][0]).toMatchObject({
target: { type: "pds", serviceUrl: "https://pds.example" },
state: { returnTo: "/welcome" }
});
});
});
describe("completeAuthorization", () => {
const response = { code: "c", state: "sid", iss: "https://pds.example" };
it("adopts the session for the account it was issued for", async () => {
mockedFinalize.mockResolvedValue({ session: liveSession(alice), state: null });
const auth = createAuth("http://127.0.0.1:1", null);
await auth.completeAuthorization(alice, response);
expect(mockedFinalize.mock.calls[0][0].get("code")).toBe("c");
expect(auth.currentDid).toBe(alice);
expect(mockedDeleteStoredSession).not.toHaveBeenCalled();
});
it("refuses and forgets a session for any other account", async () => {
const mallory = "did:plc:mallory" as Did;
mockedFinalize.mockResolvedValue({ session: liveSession(mallory), state: null });
const auth = createAuth("http://127.0.0.1:1", null);
await expect(auth.completeAuthorization(alice, response)).rejects.toThrow(
/different account/
);
expect(mockedDeleteStoredSession).toHaveBeenCalledWith(mallory);
expect(auth.currentDid).toBeNull();
expect(auth.agent).toBeNull();
});
});
describe("resumeSignIn", () => {
const seeded = { did: alice, handle: "alice.example" };
const stash = {
hash: "#state=sid&code=c",
login: { did: alice, handle: "alice.example", returnTo: "/", at: Date.now() }
};
it("starts the exchange before it returns, so agentFor has something to park on", () => {
mockedConsumePending.mockReturnValue(stash);
mockedFinalize.mockReturnValue(Promise.withResolvers<never>().promise);
const auth = createAuth("http://127.0.0.1:1", seeded);
const resumed = resumeSignIn(auth, "/");
expect(mockedFinalize).toHaveBeenCalledTimes(1);
expect(resumed).toMatchObject({ handle: "alice.example", returnTo: "/" });
});
it("leaves the callback route to run its own exchange", () => {
mockedConsumePending.mockReturnValue(stash);
const auth = createAuth("http://127.0.0.1:1", seeded);
expect(resumeSignIn(auth, "/oauth/callback")).toBeNull();
expect(mockedConsumePending).not.toHaveBeenCalled();
});
it("does nothing without a stashed callback", () => {
mockedConsumePending.mockReturnValue(null);
const auth = createAuth("http://127.0.0.1:1", null);
expect(resumeSignIn(auth, "/")).toBeNull();
expect(mockedFinalize).not.toHaveBeenCalled();
});
it("hands a failed exchange to the caller without an unhandled rejection", async () => {
mockedConsumePending.mockReturnValue(stash);
mockedFinalize.mockRejectedValue(new LoginError("unknown state provided"));
const auth = createAuth("http://127.0.0.1:1", seeded);
const resumed = resumeSignIn(auth, "/");
await expect(resumed?.done).rejects.toThrow(/unknown state/);
expect(auth.state.kind).toBe("logged-out");
});
});