105 lines
3.0 KiB
TypeScript
105 lines
3.0 KiB
TypeScript
import { createHash } from "node:crypto";
|
|
import { describe, expect, it } from "vitest";
|
|
import {
|
|
createMagicLink,
|
|
exchangeMagicLink,
|
|
InvalidLoginCodeError,
|
|
type AuthRepository,
|
|
type PendingLoginCode,
|
|
} from "../src/index";
|
|
|
|
class MemoryAuthRepository implements AuthRepository {
|
|
loginCode: PendingLoginCode | undefined;
|
|
consumedHash: string | undefined;
|
|
|
|
async saveLoginCode(code: PendingLoginCode) {
|
|
this.loginCode = code;
|
|
}
|
|
|
|
async exchangeLoginCode(input: Parameters<AuthRepository["exchangeLoginCode"]>[0]) {
|
|
this.consumedHash = input.loginCodeHash;
|
|
if (
|
|
!this.loginCode ||
|
|
this.loginCode.tokenHash !== input.loginCodeHash ||
|
|
this.loginCode.expiresAt <= input.now
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
this.loginCode = undefined;
|
|
return {
|
|
user: {
|
|
id: "01JQ0000000000000000000000",
|
|
discordUserId: "123456789012345678",
|
|
discordUsername: "steve",
|
|
firstName: null,
|
|
},
|
|
isNewUser: true,
|
|
};
|
|
}
|
|
}
|
|
|
|
const identity = {
|
|
id: "123456789012345678",
|
|
username: "steve",
|
|
globalName: "Steve",
|
|
};
|
|
const now = new Date("2026-08-01T12:00:00.000Z");
|
|
|
|
function hash(value: string) {
|
|
return createHash("sha256").update(value).digest("hex");
|
|
}
|
|
|
|
describe("Discord magic-link authentication", () => {
|
|
it("returns a link while persisting only the token hash", async () => {
|
|
const repository = new MemoryAuthRepository();
|
|
const result = await createMagicLink(identity, {
|
|
repository,
|
|
appUrl: "https://accounts.example.com",
|
|
now: () => now,
|
|
randomToken: () => "private-login-token",
|
|
});
|
|
|
|
expect(result.url).toBe("https://accounts.example.com/auth/discord?code=private-login-token");
|
|
expect(result.expiresAt).toEqual(new Date("2026-08-01T12:10:00.000Z"));
|
|
expect(repository.loginCode).toMatchObject({
|
|
tokenHash: hash("private-login-token"),
|
|
discordUserId: identity.id,
|
|
discordUsername: identity.username,
|
|
});
|
|
expect(JSON.stringify(repository.loginCode)).not.toContain("private-login-token");
|
|
});
|
|
|
|
it("exchanges a valid one-time code for a session", async () => {
|
|
const repository = new MemoryAuthRepository();
|
|
await createMagicLink(identity, {
|
|
repository,
|
|
appUrl: "https://accounts.example.com",
|
|
now: () => now,
|
|
randomToken: () => "private-login-token",
|
|
});
|
|
|
|
const result = await exchangeMagicLink("private-login-token", {
|
|
repository,
|
|
now: () => new Date("2026-08-01T12:01:00.000Z"),
|
|
randomToken: () => "private-session-token",
|
|
});
|
|
|
|
expect(result.sessionToken).toBe("private-session-token");
|
|
expect(result.user.discordUserId).toBe(identity.id);
|
|
expect(repository.consumedHash).toBe(hash("private-login-token"));
|
|
});
|
|
|
|
it("rejects an expired or already-consumed code", async () => {
|
|
const repository = new MemoryAuthRepository();
|
|
|
|
await expect(
|
|
exchangeMagicLink("missing-token", {
|
|
repository,
|
|
now: () => now,
|
|
randomToken: () => "private-session-token",
|
|
}),
|
|
).rejects.toBeInstanceOf(InvalidLoginCodeError);
|
|
});
|
|
});
|