diff --git a/README.md b/README.md index 2e5ba97..103aac8 100644 --- a/README.md +++ b/README.md @@ -296,10 +296,12 @@ It's implemented based on [RFC 4226](https://tools.ietf.org/html/rfc4226) and [R HOTP generates a one-time password based on a counter value and a secret key. The counter should be incremented for each new OTP. ```ts -import { generateHOTP } from "@better-auth/utils/otp"; +import { createOTP } from "@better-auth/utils/otp"; const secret = "my-super-secret-key"; const counter = 1234; -const otp = generateHOTP(secret, counter); +const otp = createOTP(secret, { + digits: 6, +}).hotp(counter); ``` ### Generating TOTP @@ -307,9 +309,12 @@ const otp = generateHOTP(secret, counter); TOTP generates a one-time password based on the current time and a secret key. The time step is typically 30 seconds. ```ts -import { generateTOTP } from "@better-auth/utils/otp"; +import { createOTP } from "@better-auth/utils/otp"; const secret = "my-super-secret-key" -const otp = generateTOTP(secret); +const otp = createOTP(secret, { + digits: 6, + period: 30, +}).totp(); ``` ### Verifying TOTP @@ -317,29 +322,33 @@ const otp = generateTOTP(secret); Verify a TOTP against the secret key and a specified time window. The default time window is 30 seconds. ```ts -import { verifyTOTP } from "@better-auth/utils/otp"; +import { createOTP } from "@better-auth/utils/otp"; const secret = "my-super-secret-key" -const isValid = verifyTOTP(secret, otp); +const isValid = createOTP(secret, { + digits: 6, + period: 30, +}).verify(otp); ``` You can also specify the time window in seconds. ```ts -import { verifyTOTP } from "@better-auth/utils"; -const isValid = verifyTOTP(secret, otp, { window: 60 }); +import { createOTP } from "@better-auth/utils"; +const isValid = createOTP(secret).verify(otp, { window: 60 }); ``` -### Generate QR Code +### Generate URL for Authenticator App -Generate a QR code URL for provisioning a TOTP secret key in an authenticator app. +Generate a URL for provisioning a TOTP secret key in an authenticator app. + +- `issuer` - The name of the service or app. +- `account` - The user's email or username. ```ts -import { generateQRCode } from "@better-auth/utils/otp"; +import { createOTP } from "@better-auth/utils/otp"; const secret = "my-super-secret-key"; -const label = "My Account"; - -const qrCodeUrl = generateQRCode(secret, label); +const qrCodeUrl = createOTP(secret).url("my-app", "user@email.com"); ``` ## License diff --git a/src/hash.test.ts b/src/hash.test.ts index 5eb1886..34ef801 100644 --- a/src/hash.test.ts +++ b/src/hash.test.ts @@ -7,23 +7,23 @@ describe("digest", () => { describe("SHA algorithms", () => { it("computes SHA-256 hash in raw format", async () => { - const hash = await createHash("SHA-256")(inputString); + const hash = await createHash("SHA-256").digest(inputString); expect(hash).toBeInstanceOf(ArrayBuffer); }); it("computes SHA-512 hash in raw format", async () => { - const hash = await createHash("SHA-512")(inputBuffer); + const hash = await createHash("SHA-512").digest(inputBuffer); expect(hash).toBeInstanceOf(ArrayBuffer); }); it("computes SHA-256 hash in hex encoding", async () => { - const hash = await createHash("SHA-256", "hex")(inputString); + const hash = await createHash("SHA-256", "hex").digest(inputString); expect(typeof hash).toBe("string"); expect(hash).toMatch(/^[a-f0-9]{64}$/); }); it("computes SHA-512 hash in hex encoding", async () => { - const hash = await createHash("SHA-512", "hex")(inputBuffer); + const hash = await createHash("SHA-512", "hex").digest(inputBuffer); expect(typeof hash).toBe("string"); expect(hash).toMatch(/^[a-f0-9]{128}$/); }); @@ -31,28 +31,28 @@ describe("digest", () => { describe("Input variations", () => { it("handles input as a string", async () => { - const hash = await createHash("SHA-256")(inputString); + const hash = await createHash("SHA-256").digest(inputString); expect(hash).toBeInstanceOf(ArrayBuffer); }); it("handles input as an ArrayBuffer", async () => { - const hash = await createHash("SHA-256")(inputBuffer.buffer); + const hash = await createHash("SHA-256").digest(inputBuffer.buffer); expect(hash).toBeInstanceOf(ArrayBuffer); }); it("handles input as an ArrayBufferView", async () => { - const hash = await createHash("SHA-256")(new Uint8Array(inputBuffer)); + const hash = await createHash("SHA-256").digest(new Uint8Array(inputBuffer)); expect(hash).toBeInstanceOf(ArrayBuffer); }); }); describe("Error handling", () => { it("throws an error for unsupported hash algorithms", async () => { - await expect(createHash("SHA-10" as any)(inputString)).rejects.toThrow(); + await expect(createHash("SHA-10" as any).digest(inputString)).rejects.toThrow(); }); it("throws an error for invalid input types", async () => { - await expect(createHash("SHA-256")({} as any)).rejects.toThrow(); + await expect(createHash("SHA-256").digest({} as any)).rejects.toThrow(); }); }); }); diff --git a/src/hash.ts b/src/hash.ts index bdc5337..caf1ffb 100644 --- a/src/hash.ts +++ b/src/hash.ts @@ -5,26 +5,28 @@ export function createHash( algorithm: SHAFamily, encoding?: Encoding, ) { - return async (input: string | ArrayBuffer | TypedArray,): Promise => { - const encoder = new TextEncoder(); - const data = typeof input === "string" ? encoder.encode(input) : input; - const hashBuffer = await crypto.subtle.digest(algorithm, data); + return { + digest: async (input: string | ArrayBuffer | TypedArray,): Promise => { + const encoder = new TextEncoder(); + const data = typeof input === "string" ? encoder.encode(input) : input; + const hashBuffer = await crypto.subtle.digest(algorithm, data); - if (encoding === "hex") { - const hashArray = Array.from(new Uint8Array(hashBuffer)); - const hashHex = hashArray - .map((b) => b.toString(16).padStart(2, "0")) - .join(""); - return hashHex as any; - } + if (encoding === "hex") { + const hashArray = Array.from(new Uint8Array(hashBuffer)); + const hashHex = hashArray + .map((b) => b.toString(16).padStart(2, "0")) + .join(""); + return hashHex as any; + } - if (encoding === "base64" || encoding === "base64url" || encoding === "base64urlnopad") { - const hashBase64 = base64.encode(hashBuffer, { - urlSafe: encoding !== "base64", - padding: encoding !== "base64urlnopad", - }); - return hashBase64 as any; + if (encoding === "base64" || encoding === "base64url" || encoding === "base64urlnopad") { + const hashBase64 = base64.encode(hashBuffer, { + urlSafe: encoding !== "base64", + padding: encoding !== "base64urlnopad", + }); + return hashBase64 as any; + } + return hashBuffer as any; } - return hashBuffer as any; } } \ No newline at end of file diff --git a/src/otp.test.ts b/src/otp.test.ts index f8a4608..501d47e 100644 --- a/src/otp.test.ts +++ b/src/otp.test.ts @@ -1,15 +1,15 @@ import { describe, it, expect, vi } from "vitest"; -import { generateHOTP, generateTOTP, verifyTOTP } from "./otp"; +import { createOTP } from "./otp"; + describe("HOTP and TOTP Generation Tests", () => { it("should generate a valid HOTP for a given counter", async () => { const key = "1234567890"; const counter = 1; const digits = 6; - - const otp = await generateHOTP(key, { - counter, - }); + const otp = await createOTP(key, { + digits + }).hotp(counter); expect(otp).toBeTypeOf("string"); expect(otp.length).toBe(digits); }); @@ -19,16 +19,14 @@ describe("HOTP and TOTP Generation Tests", () => { const counter = 1; await expect( - generateHOTP(key, { - counter, - digits: 9, - }), + createOTP(key, { + digits: 9 + }).hotp(counter) ).rejects.toThrow("Digits must be between 1 and 8"); await expect( - generateHOTP(key, { - counter, - digits: 0, - }), + createOTP(key, { + digits: 0 + }).hotp(counter) ).rejects.toThrow("Digits must be between 1 and 8"); }); @@ -36,7 +34,9 @@ describe("HOTP and TOTP Generation Tests", () => { const secret = "1234567890"; const digits = 6; - const otp = await generateTOTP(secret, { digits }); + const otp = await createOTP(secret, { + digits + }).totp(); expect(otp).toBeTypeOf("string"); expect(otp.length).toBe(digits); }); @@ -46,17 +46,23 @@ describe("HOTP and TOTP Generation Tests", () => { const seconds = 30; const digits = 6; - const otp1 = await generateTOTP(secret, { digits, period: seconds }); + const otp1 = await createOTP(secret, { + period: seconds, + digits + }).totp(); vi.useFakeTimers(); await vi.advanceTimersByTimeAsync(30000); - const otp2 = await generateTOTP(secret, { digits }); + const otp2 = await createOTP(secret, { + period: seconds, + digits + }).totp(); expect(otp1).not.toBe(otp2); }); it("should verify correct TOTP against generated value", async () => { const secret = "1234567890"; - const totp = await generateTOTP(secret, { digits: 6 }); - const isValid = await verifyTOTP(totp, { secret }); + const totp = await createOTP(secret).totp(); + const isValid = await createOTP(secret).verify(totp); expect(isValid).toBe(true); }); @@ -64,22 +70,22 @@ describe("HOTP and TOTP Generation Tests", () => { const secret = "1234567890"; const invalidTOTP = "000000"; - const isValid = await verifyTOTP(invalidTOTP, { secret }); + const isValid = await createOTP(secret).verify(invalidTOTP); console.log(isValid); expect(isValid).toBe(false); }); it("should verify TOTP within the window", async () => { const secret = "1234567890"; - const totp = await generateTOTP(secret, { digits: 6 }); - const isValid = await verifyTOTP(totp, { secret, window: 1 }); + const totp = await createOTP(secret).totp(); + const isValid = await createOTP(secret).verify(totp, { window: 1 }); expect(isValid).toBe(true); }); it("should return false for TOTP outside the window", async () => { const secret = "1234567890"; - const totp = await generateTOTP(secret, { digits: 6 }); - const isValid = await verifyTOTP(totp, { secret, window: -1 }); + const totp = await createOTP(secret).totp(); + const isValid = await createOTP(secret).verify(totp, { window: -1 }); expect(isValid).toBe(false); }); }); diff --git a/src/otp.ts b/src/otp.ts index 31458bd..5b893bc 100644 --- a/src/otp.ts +++ b/src/otp.ts @@ -4,7 +4,7 @@ import type { SHAFamily } from "./type"; const defaultPeriod = 30; const defaultDigits = 6; -export async function generateHOTP( +async function generateHOTP( secret: string, { counter, @@ -34,7 +34,7 @@ export async function generateHOTP( return otp.toString().padStart(_digits, "0"); } -export async function generateTOTP( +async function generateTOTP( secret: string, options?: { period?: number; @@ -50,7 +50,7 @@ export async function generateTOTP( } -export async function verifyTOTP( +async function verifyTOTP( otp: string, { window = 1, @@ -81,7 +81,7 @@ export async function verifyTOTP( /** * Generate a QR code URL for the OTP secret */ -export function generateQRCode( +function generateQRCode( { issuer, account, @@ -103,4 +103,22 @@ export function generateQRCode( url.searchParams.set("digits", digits.toString()); url.searchParams.set("period", period.toString()); return url.toString(); +} + +export const createOTP = ( + secret: string, + opts?: { + digits?: number; + period?: number; + } +) => { + const digits = opts?.digits ?? defaultDigits; + const period = opts?.period ?? defaultPeriod; + return { + hotp: (counter: number) => generateHOTP(secret, { counter, digits }), + totp: () => generateTOTP(secret, { digits, period }), + verify: (otp: string, options?: { window?: number }) => + verifyTOTP(otp, { secret, digits, period, ...options }), + url: (issuer: string, account: string) => generateQRCode({ issuer, account, secret, digits, period }), + }; } \ No newline at end of file