From e813f32da55bae7c03f0edc9c2f0b84f75aeaf2f Mon Sep 17 00:00:00 2001 From: pilcrow Date: Tue, 8 Oct 2024 00:14:40 +0900 Subject: [PATCH] Replace Oslo (#1708) --- .auri/$rwuelhob.md | 6 + .auri/$ymtw2zct.md | 6 + docs/pages/basics/sessions.md | 2 +- docs/pages/basics/users.md | 6 +- docs/pages/reference/main/Cookie.md | 7 -- docs/pages/reference/main/Cookie/index.md | 67 +++++++++++ docs/pages/reference/main/Cookie/serialize.md | 17 +++ .../main/Lucia/createBlankSessionCookie.md | 2 +- .../main/Lucia/createSessionCookie.md | 2 +- docs/pages/reference/main/Scrypt/index.md | 2 +- docs/pages/reference/main/TimeSpan.md | 7 -- docs/pages/reference/main/TimeSpan/index.md | 50 ++++++++ .../reference/main/TimeSpan/milliseconds.md | 20 ++++ docs/pages/reference/main/TimeSpan/seconds.md | 20 ++++ .../reference/main/verifyRequestOrigin.md | 29 ++++- packages/adapter-test/src/index.ts | 11 +- packages/lucia/package.json | 3 +- packages/lucia/src/cookie.ts | 111 ++++++++++++++++++ packages/lucia/src/core.ts | 9 +- packages/lucia/src/crypto.test.ts | 10 +- packages/lucia/src/crypto.ts | 41 ++++--- packages/lucia/src/date.ts | 46 ++++++++ packages/lucia/src/index.ts | 7 +- packages/lucia/src/request.ts | 29 +++++ packages/lucia/src/scrypt/index.test.ts | 7 +- 25 files changed, 450 insertions(+), 67 deletions(-) create mode 100644 .auri/$rwuelhob.md create mode 100644 .auri/$ymtw2zct.md delete mode 100644 docs/pages/reference/main/Cookie.md create mode 100644 docs/pages/reference/main/Cookie/index.md create mode 100644 docs/pages/reference/main/Cookie/serialize.md delete mode 100644 docs/pages/reference/main/TimeSpan.md create mode 100644 docs/pages/reference/main/TimeSpan/index.md create mode 100644 docs/pages/reference/main/TimeSpan/milliseconds.md create mode 100644 docs/pages/reference/main/TimeSpan/seconds.md create mode 100644 packages/lucia/src/cookie.ts create mode 100644 packages/lucia/src/date.ts create mode 100644 packages/lucia/src/request.ts diff --git a/.auri/$rwuelhob.md b/.auri/$rwuelhob.md new file mode 100644 index 000000000..080e519d2 --- /dev/null +++ b/.auri/$rwuelhob.md @@ -0,0 +1,6 @@ +--- +package: "@lucia-auth/adapter-test" # package name +type: "patch" # "major", "minor", "patch" +--- + +Update dependencies. \ No newline at end of file diff --git a/.auri/$ymtw2zct.md b/.auri/$ymtw2zct.md new file mode 100644 index 000000000..5b9626f43 --- /dev/null +++ b/.auri/$ymtw2zct.md @@ -0,0 +1,6 @@ +--- +package: "lucia" # package name +type: "patch" # "major", "minor", "patch" +--- + +Update dependencies. \ No newline at end of file diff --git a/docs/pages/basics/sessions.md b/docs/pages/basics/sessions.md index ecb880fa9..c7a8f3011 100644 --- a/docs/pages/basics/sessions.md +++ b/docs/pages/basics/sessions.md @@ -132,7 +132,7 @@ See the [Validate session cookies](/guides/validate-session-cookies) and [Valida ### Create session cookies -You can create a session cookie for a session with [`Lucia.createSessionCookie()`](/reference/main/Lucia/createSessionCookie). It takes a session and returns a new [`Cookie`](/reference/main/Cookie) instance. You can either use [`Cookie.serialize()`](https://oslo.js.org/reference/cookie/Cookie/serialize) to create `Set-Cookie` HTTP header value, or use your framework's API by accessing the name, value, and session. +You can create a session cookie for a session with [`Lucia.createSessionCookie()`](/reference/main/Lucia/createSessionCookie). It takes a session and returns a new [`Cookie`](/reference/main/Cookie) instance. You can either use [`Cookie.serialize()`](/reference/main/Cookie) to create `Set-Cookie` HTTP header value, or use your framework's API by accessing the name, value, and session. ```ts const sessionCookie = lucia.createSessionCookie(session.id); diff --git a/docs/pages/basics/users.md b/docs/pages/basics/users.md index d7d8511a0..63710227c 100644 --- a/docs/pages/basics/users.md +++ b/docs/pages/basics/users.md @@ -25,16 +25,12 @@ await db.createUser({ }); ``` -Use Lucia's [`generateId()`](/reference/main/generateIdFromEntropySize) or Oslo's [`generateRandomString()`](https://oslo.js.org/reference/crypto/generateRandomString) if you're looking for a more customizable option. +Use Lucia's [`generateId()`](/reference/main/generateIdFromEntropySize). ```ts import { generateId } from "lucia"; const id = generateId(15); - -import { generateRandomString, alphabet } from "oslo/crypto"; - -const id = generateRandomString(15, alphabet("a-z", "A-Z", "0-9")); ``` ## Define user attributes diff --git a/docs/pages/reference/main/Cookie.md b/docs/pages/reference/main/Cookie.md deleted file mode 100644 index 690b9b75f..000000000 --- a/docs/pages/reference/main/Cookie.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -title: "Cookie" ---- - -# `Cookie` - -See [`Cookie`](https://oslo.js.org/reference/cookie/Cookie) from `oslo/cookie`. diff --git a/docs/pages/reference/main/Cookie/index.md b/docs/pages/reference/main/Cookie/index.md new file mode 100644 index 000000000..4bb28ff5a --- /dev/null +++ b/docs/pages/reference/main/Cookie/index.md @@ -0,0 +1,67 @@ +--- +title: "Cookie" +--- + +# `Cookie` + +Represents a cookie. + +## Constructor + +```ts +//$ CookieAttributes=/reference/main/CookieAttributes +function constructor(name: string, value: string, attributes?: $$CookieAttributes): this; +``` + +### Parameters + +- `name` +- `value` +- `attributes` + +## Methods + +- [`serialize()`](/reference/main/Cookie/serialize) + +## Properties + +```ts +//$ CookieAttributes=/reference/main/CookieAttributes +interface Properties { + name: string; + value: string; + attributes: $$CookieAttributes; +} +``` + +- `name` +- `value` +- `attributes` + +## Example + +```ts +import { Cookie } from "lucia"; + +const sessionCookie = new Cookie("session", sessionId, { + maxAge: 60 * 60 * 24, + httpOnly: true, + secure: true, + path: "/" +}); +response.headers.set("Set-Cookie", sessionCookie.serialize()); +``` + +If your framework provides an API for setting cookies: + +```ts +import { Cookie } from "lucia"; + +const sessionCookie = new Cookie("session", sessionId, { + maxAge: 60 * 60 * 24, + httpOnly: true, + secure: true, + path: "/" +}); +setCookie(sessionCookie.name, sessionCookie.value, sessionCookie.attributes); +``` diff --git a/docs/pages/reference/main/Cookie/serialize.md b/docs/pages/reference/main/Cookie/serialize.md new file mode 100644 index 000000000..bc48612aa --- /dev/null +++ b/docs/pages/reference/main/Cookie/serialize.md @@ -0,0 +1,17 @@ +--- +title: "Cookie.serialize()" +--- + +# `Cookie.serialize()` + +Serializes cookie for `Set-Cookie` header. + +```ts +function serialize(): string; +``` + +## Example + +```ts +response.headers.set("Set-Cookie", cookie.serialize()); +``` diff --git a/docs/pages/reference/main/Lucia/createBlankSessionCookie.md b/docs/pages/reference/main/Lucia/createBlankSessionCookie.md index d97eb491c..290f77033 100644 --- a/docs/pages/reference/main/Lucia/createBlankSessionCookie.md +++ b/docs/pages/reference/main/Lucia/createBlankSessionCookie.md @@ -9,6 +9,6 @@ Method of [`Lucia`](/reference/main/Lucia). Creates a new cookie with a blank va ## Definition ```ts -//$ Cookie=/reference/cookie/Cookie +//$ Cookie=/reference/main/Cookie function createBlankSessionCookie(): $$Cookie; ``` diff --git a/docs/pages/reference/main/Lucia/createSessionCookie.md b/docs/pages/reference/main/Lucia/createSessionCookie.md index 085cbd38e..f72a51ea6 100644 --- a/docs/pages/reference/main/Lucia/createSessionCookie.md +++ b/docs/pages/reference/main/Lucia/createSessionCookie.md @@ -9,6 +9,6 @@ Method of [`Lucia`](/reference/main/Lucia). Creates a new session cookie. ## Definition ```ts -//$ Cookie=/reference/cookie/Cookie +//$ Cookie=/reference/main/Cookie function createSessionCookie(sessionId: string): $$Cookie; ``` diff --git a/docs/pages/reference/main/Scrypt/index.md b/docs/pages/reference/main/Scrypt/index.md index 34f99bc09..d501691f7 100644 --- a/docs/pages/reference/main/Scrypt/index.md +++ b/docs/pages/reference/main/Scrypt/index.md @@ -8,7 +8,7 @@ A pure JS implementation of Scrypt. Provides methods for hashing passwords and v The output hash is a combination of the scrypt hash and the 32-bytes salt, in the format of `:`. -Since it's pure JS, it is anywhere from 2~3 times slower than implementations based on native code. See Oslo's [`Scrypt`](https://oslo.js.org/reference/password/Scrypt) for a faster API (Node.js-only). +Since it's pure JS, it is anywhere from 2~3 times slower than implementations based on native code. ## Constructor diff --git a/docs/pages/reference/main/TimeSpan.md b/docs/pages/reference/main/TimeSpan.md deleted file mode 100644 index 3d8d3ad5c..000000000 --- a/docs/pages/reference/main/TimeSpan.md +++ /dev/null @@ -1,7 +0,0 @@ ---- -title: "TimeSpan" ---- - -# `TimeSpan` - -See [`TimeSpan`](https://oslo.js.org/reference/main/TimeSpan) from `oslo`. diff --git a/docs/pages/reference/main/TimeSpan/index.md b/docs/pages/reference/main/TimeSpan/index.md new file mode 100644 index 000000000..b5bfc86dd --- /dev/null +++ b/docs/pages/reference/main/TimeSpan/index.md @@ -0,0 +1,50 @@ +--- +title: "TimeSpan" +--- + +# `TimeSpan` + +Represents a time-span. Supports negative values. + +## Constructor + +```ts +//$ TimeSpanUnit=/reference/main/TimeSpanUnit +function constructor(value: number, unit: $$TimeSpanUnit): this; +``` + +### Parameters + +- `value` +- `unit`: `ms` for milliseconds, `s` for seconds, etc + +## Methods + +- [`milliseconds()`](/reference/main/TimeSpan/milliseconds) +- [`seconds()`](/reference/main/TimeSpan/seconds) + +## Properties + +```ts +//$ TimeSpanUnit=/reference/main/TimeSpanUnit +interface Properties { + unit: $$TimeSpanUnit; + value: number; +} +``` + +- `unit` +- `value` + +## Example + +```ts +import { TimeSpan } from "oslo"; + +const halfSeconds = new TimeSpan(500, "ms"); +const tenSeconds = new TimeSpan(10, "s"); +const halfHour = new TimeSpan(30, "m"); +const oneHour = new TimeSpan(1, "h"); +const oneDay = new TimeSpan(1, "d"); +const oneWeek = new TimeSpan(1, "w"); +``` diff --git a/docs/pages/reference/main/TimeSpan/milliseconds.md b/docs/pages/reference/main/TimeSpan/milliseconds.md new file mode 100644 index 000000000..12baddb0d --- /dev/null +++ b/docs/pages/reference/main/TimeSpan/milliseconds.md @@ -0,0 +1,20 @@ +--- +title: "Timespan.milliseconds()" +--- + +# `Timespan.milliseconds()` + +Returns the time-span in milliseconds. + +## Definition + +```ts +function milliseconds(): number; +``` + +## Example + +```ts +// 60 * 1000 = 60,000 ms +new TimeSpan("60", "s").milliseconds(); +``` diff --git a/docs/pages/reference/main/TimeSpan/seconds.md b/docs/pages/reference/main/TimeSpan/seconds.md new file mode 100644 index 000000000..5165b37aa --- /dev/null +++ b/docs/pages/reference/main/TimeSpan/seconds.md @@ -0,0 +1,20 @@ +--- +title: "Timespan.seconds()" +--- + +# `Timespan.seconds()` + +Returns the time-span in seconds. + +## Definition + +```ts +function seconds(): number; +``` + +## Example + +```ts +// 60 * 60 = 3600 s +new TimeSpan(1, "h").seconds(); +``` diff --git a/docs/pages/reference/main/verifyRequestOrigin.md b/docs/pages/reference/main/verifyRequestOrigin.md index 51803964c..c5bed8cf0 100644 --- a/docs/pages/reference/main/verifyRequestOrigin.md +++ b/docs/pages/reference/main/verifyRequestOrigin.md @@ -2,6 +2,31 @@ title: "verifyRequestOrigin()" --- -# `Cookie` +# `verifyRequestOrigin()` -See [`verifyRequestOrigin()`](https://oslo.js.org/reference/request/verifyRequestOrigin) from `oslo/request`. +Verifies the request originates from a trusted origin by comparing the `Origin` header and host (e.g. `Host` header). + +## Definition + +```ts +function verifyRequestOrigin(origin: string, allowedDomains: string[]): boolean; +``` + +### Parameters + +- `origin`: `Origin` header +- `allowedDomains`: Allowed request origins, full URL or URL host + +## Example + +```ts +import { verifyRequestOrigin } from "lucia"; + +// true +verifyRequestOrigin("https://example.com", ["example.com"]); +verifyRequestOrigin("https://example.com", ["https://example.com"]); + +// false +verifyRequestOrigin("https://foo.example.com", ["example.com"]); +verifyRequestOrigin("https://example.com", ["foo.example.com"]); +``` diff --git a/packages/adapter-test/src/index.ts b/packages/adapter-test/src/index.ts index 156867a14..411d33f6a 100644 --- a/packages/adapter-test/src/index.ts +++ b/packages/adapter-test/src/index.ts @@ -1,11 +1,10 @@ -import { Adapter, DatabaseSession, DatabaseUser } from "lucia"; -import { generateRandomString, alphabet } from "oslo/crypto"; +import { Adapter, DatabaseSession, DatabaseUser, generateId } from "lucia"; import assert from "node:assert/strict"; export const databaseUser: DatabaseUser = { - id: generateRandomString(15, alphabet("0-9", "a-z")), + id: generateId(15), attributes: { - username: generateRandomString(15, alphabet("0-9", "a-z")) + username: generateId(15) } }; @@ -13,7 +12,7 @@ export async function testAdapter(adapter: Adapter) { console.log(`\n\x1B[38;5;63;1m[start] \x1B[0mRunning adapter tests\x1B[0m\n`); const databaseSession: DatabaseSession = { userId: databaseUser.id, - id: generateRandomString(40, alphabet("0-9", "a-z")), + id: generateId(40), // get random date with 0ms expiresAt: new Date(Math.floor(Date.now() / 1000) * 1000 + 10_000), attributes: { @@ -54,7 +53,7 @@ export async function testAdapter(adapter: Adapter) { await test("deleteExpiredSessions() deletes all expired sessions", async () => { const expiredSession: DatabaseSession = { userId: databaseUser.id, - id: generateRandomString(40, alphabet("0-9", "a-z")), + id: generateId(40), expiresAt: new Date(Math.floor(Date.now() / 1000) * 1000 - 10_000), attributes: { country: "us" diff --git a/packages/lucia/package.json b/packages/lucia/package.json index d0b70d003..c94bdb466 100644 --- a/packages/lucia/package.json +++ b/packages/lucia/package.json @@ -35,6 +35,7 @@ "vitest": "^0.33.0" }, "dependencies": { - "oslo": "1.2.0" + "@oslojs/crypto": "^1.0.1", + "@oslojs/encoding": "^1.1.0" } } diff --git a/packages/lucia/src/cookie.ts b/packages/lucia/src/cookie.ts new file mode 100644 index 000000000..fcc0e04fd --- /dev/null +++ b/packages/lucia/src/cookie.ts @@ -0,0 +1,111 @@ +import type { TimeSpan } from "./date.js"; + +export interface CookieAttributes { + secure?: boolean; + path?: string; + domain?: string; + sameSite?: "lax" | "strict" | "none"; + httpOnly?: boolean; + maxAge?: number; + expires?: Date; +} + +export function serializeCookie(name: string, value: string, attributes: CookieAttributes): string { + const keyValueEntries: Array<[string, string] | [string]> = []; + keyValueEntries.push([encodeURIComponent(name), encodeURIComponent(value)]); + if (attributes?.domain !== undefined) { + keyValueEntries.push(["Domain", attributes.domain]); + } + if (attributes?.expires !== undefined) { + keyValueEntries.push(["Expires", attributes.expires.toUTCString()]); + } + if (attributes?.httpOnly) { + keyValueEntries.push(["HttpOnly"]); + } + if (attributes?.maxAge !== undefined) { + keyValueEntries.push(["Max-Age", attributes.maxAge.toString()]); + } + if (attributes?.path !== undefined) { + keyValueEntries.push(["Path", attributes.path]); + } + if (attributes?.sameSite === "lax") { + keyValueEntries.push(["SameSite", "Lax"]); + } + if (attributes?.sameSite === "none") { + keyValueEntries.push(["SameSite", "None"]); + } + if (attributes?.sameSite === "strict") { + keyValueEntries.push(["SameSite", "Strict"]); + } + if (attributes?.secure) { + keyValueEntries.push(["Secure"]); + } + return keyValueEntries.map((pair) => pair.join("=")).join("; "); +} + +export function parseCookies(header: string): Map { + const cookies = new Map(); + const items = header.split("; "); + for (const item of items) { + const pair = item.split("="); + const rawKey = pair[0]; + const rawValue = pair[1] ?? ""; + if (!rawKey) continue; + cookies.set(decodeURIComponent(rawKey), decodeURIComponent(rawValue)); + } + return cookies; +} + +export class CookieController { + constructor( + cookieName: string, + baseCookieAttributes: CookieAttributes, + cookieOptions?: { + expiresIn?: TimeSpan; + } + ) { + this.cookieName = cookieName; + this.cookieExpiresIn = cookieOptions?.expiresIn ?? null; + this.baseCookieAttributes = baseCookieAttributes; + } + + public cookieName: string; + + private cookieExpiresIn: TimeSpan | null; + private baseCookieAttributes: CookieAttributes; + + public createCookie(value: string): Cookie { + return new Cookie(this.cookieName, value, { + ...this.baseCookieAttributes, + maxAge: this.cookieExpiresIn?.seconds() + }); + } + + public createBlankCookie(): Cookie { + return new Cookie(this.cookieName, "", { + ...this.baseCookieAttributes, + maxAge: 0 + }); + } + + public parse(header: string): string | null { + const cookies = parseCookies(header); + return cookies.get(this.cookieName) ?? null; + } +} + +export class Cookie { + constructor(name: string, value: string, attributes: CookieAttributes) { + this.name = name; + this.value = value; + this.attributes = attributes; + } + + public name: string; + public value: string; + public attributes: CookieAttributes; + + public serialize(): string { + return serializeCookie(this.name, this.value, this.attributes); + } +} diff --git a/packages/lucia/src/core.ts b/packages/lucia/src/core.ts index 70052bb28..aaec96229 100644 --- a/packages/lucia/src/core.ts +++ b/packages/lucia/src/core.ts @@ -1,7 +1,7 @@ -import { TimeSpan, createDate, isWithinExpirationDate } from "oslo"; -import { CookieController } from "oslo/cookie"; +import { TimeSpan, createDate, isWithinExpirationDate } from "./date.js"; +import { CookieController } from "./cookie.js"; +import { generateIdFromEntropySize } from "./crypto.js"; -import type { Cookie } from "oslo/cookie"; import type { Adapter } from "./database.js"; import type { RegisteredDatabaseSessionAttributes, @@ -9,8 +9,7 @@ import type { RegisteredLucia, UserId } from "./index.js"; -import { CookieAttributes } from "oslo/cookie"; -import { generateIdFromEntropySize } from "./crypto.js"; +import type { Cookie, CookieAttributes } from "./cookie.js"; type SessionAttributes = RegisteredLucia extends Lucia ? _SessionAttributes diff --git a/packages/lucia/src/crypto.test.ts b/packages/lucia/src/crypto.test.ts index 1818c345f..023ef60c1 100644 --- a/packages/lucia/src/crypto.test.ts +++ b/packages/lucia/src/crypto.test.ts @@ -1,22 +1,22 @@ import { test, expect } from "vitest"; import { Scrypt, LegacyScrypt, generateIdFromEntropySize } from "./crypto.js"; -import { encodeHex } from "oslo/encoding"; +import { encodeHexLowerCase } from "@oslojs/encoding"; test("validateScryptHash() validates hashes generated with generateScryptHash()", async () => { - const password = encodeHex(crypto.getRandomValues(new Uint8Array(32))); + const password = encodeHexLowerCase(crypto.getRandomValues(new Uint8Array(32))); const scrypt = new Scrypt(); const hash = await scrypt.hash(password); await expect(scrypt.verify(hash, password)).resolves.toBe(true); - const falsePassword = encodeHex(crypto.getRandomValues(new Uint8Array(32))); + const falsePassword = encodeHexLowerCase(crypto.getRandomValues(new Uint8Array(32))); await expect(scrypt.verify(hash, falsePassword)).resolves.toBe(false); }); test("LegacyScrypt", async () => { - const password = encodeHex(crypto.getRandomValues(new Uint8Array(32))); + const password = encodeHexLowerCase(crypto.getRandomValues(new Uint8Array(32))); const scrypt = new LegacyScrypt(); const hash = await scrypt.hash(password); await expect(scrypt.verify(hash, password)).resolves.toBe(true); - const falsePassword = encodeHex(crypto.getRandomValues(new Uint8Array(32))); + const falsePassword = encodeHexLowerCase(crypto.getRandomValues(new Uint8Array(32))); await expect(scrypt.verify(hash, falsePassword)).resolves.toBe(false); }); diff --git a/packages/lucia/src/crypto.ts b/packages/lucia/src/crypto.ts index 4d0804341..bf312dd98 100644 --- a/packages/lucia/src/crypto.ts +++ b/packages/lucia/src/crypto.ts @@ -1,12 +1,11 @@ -import { encodeHex, decodeHex, base32 } from "oslo/encoding"; -import { constantTimeEqual, generateRandomString, alphabet } from "oslo/crypto"; +import { decodeHex, encodeBase32LowerCaseNoPadding, encodeHexLowerCase } from "@oslojs/encoding"; +import { generateRandomString } from "@oslojs/crypto/random"; +import { constantTimeEqual } from "@oslojs/crypto/subtle"; import { scrypt } from "./scrypt/index.js"; -import type { PasswordHashingAlgorithm } from "oslo/password"; +import type { RandomReader } from "@oslojs/crypto/random"; -export type { PasswordHashingAlgorithm } from "oslo/password"; - -async function generateScryptKey(data: string, salt: string, blockSize = 16): Promise { +async function generateScryptKey(data: string, salt: string, blockSize = 16): Promise { const encodedData = new TextEncoder().encode(data); const encodedSalt = new TextEncoder().encode(salt); const keyUint8Array = await scrypt(encodedData, encodedSalt, { @@ -15,27 +14,30 @@ async function generateScryptKey(data: string, salt: string, blockSize = 16): Pr p: 1, dkLen: 64 }); - return keyUint8Array; + return new Uint8Array(keyUint8Array); } +const random: RandomReader = { + read(bytes: Uint8Array): void { + crypto.getRandomValues(bytes); + } +}; + export function generateId(length: number): string { - return generateRandomString(length, alphabet("0-9", "a-z")); + const alphabet = "abcdefghijklmnopqrstuvwxyz0123456789"; + return generateRandomString(random, alphabet, length); } export function generateIdFromEntropySize(size: number): string { const buffer = crypto.getRandomValues(new Uint8Array(size)); - return base32 - .encode(buffer, { - includePadding: false - }) - .toLowerCase(); + return encodeBase32LowerCaseNoPadding(buffer); } export class Scrypt implements PasswordHashingAlgorithm { async hash(password: string): Promise { - const salt = encodeHex(crypto.getRandomValues(new Uint8Array(16))); + const salt = encodeHexLowerCase(crypto.getRandomValues(new Uint8Array(16))); const key = await generateScryptKey(password.normalize("NFKC"), salt); - return `${salt}:${encodeHex(key)}`; + return `${salt}:${encodeHexLowerCase(key)}`; } async verify(hash: string, password: string): Promise { const parts = hash.split(":"); @@ -49,9 +51,9 @@ export class Scrypt implements PasswordHashingAlgorithm { export class LegacyScrypt implements PasswordHashingAlgorithm { async hash(password: string): Promise { - const salt = encodeHex(crypto.getRandomValues(new Uint8Array(16))); + const salt = encodeHexLowerCase(crypto.getRandomValues(new Uint8Array(16))); const key = await generateScryptKey(password.normalize("NFKC"), salt); - return `s2:${salt}:${encodeHex(key)}`; + return `s2:${salt}:${encodeHexLowerCase(key)}`; } async verify(hash: string, password: string): Promise { const parts = hash.split(":"); @@ -70,3 +72,8 @@ export class LegacyScrypt implements PasswordHashingAlgorithm { return false; } } + +export interface PasswordHashingAlgorithm { + hash(password: string): Promise; + verify(hash: string, password: string): Promise; +} diff --git a/packages/lucia/src/date.ts b/packages/lucia/src/date.ts new file mode 100644 index 000000000..903b792b8 --- /dev/null +++ b/packages/lucia/src/date.ts @@ -0,0 +1,46 @@ +export type TimeSpanUnit = "ms" | "s" | "m" | "h" | "d" | "w"; + +export class TimeSpan { + constructor(value: number, unit: TimeSpanUnit) { + this.value = value; + this.unit = unit; + } + + public value: number; + public unit: TimeSpanUnit; + + public milliseconds(): number { + if (this.unit === "ms") { + return this.value; + } + if (this.unit === "s") { + return this.value * 1000; + } + if (this.unit === "m") { + return this.value * 1000 * 60; + } + if (this.unit === "h") { + return this.value * 1000 * 60 * 60; + } + if (this.unit === "d") { + return this.value * 1000 * 60 * 60 * 24; + } + return this.value * 1000 * 60 * 60 * 24 * 7; + } + + public seconds(): number { + return this.milliseconds() / 1000; + } + + public transform(x: number): TimeSpan { + return new TimeSpan(Math.round(this.milliseconds() * x), "ms"); + } +} + +export function isWithinExpirationDate(date: Date): boolean { + return Date.now() < date.getTime(); +} + +export function createDate(timeSpan: TimeSpan): Date { + return new Date(Date.now() + timeSpan.milliseconds()); +} diff --git a/packages/lucia/src/index.ts b/packages/lucia/src/index.ts index 05100cb81..6512a34ac 100644 --- a/packages/lucia/src/index.ts +++ b/packages/lucia/src/index.ts @@ -1,8 +1,8 @@ export { Lucia } from "./core.js"; export { Scrypt, LegacyScrypt, generateId, generateIdFromEntropySize } from "./crypto.js"; -export { TimeSpan } from "oslo"; -export { Cookie } from "oslo/cookie"; -export { verifyRequestOrigin } from "oslo/request"; +export { TimeSpan } from "./date.js"; +export { Cookie, CookieAttributes } from "./cookie.js"; +export { verifyRequestOrigin } from "./request.js"; export type { User, @@ -12,7 +12,6 @@ export type { } from "./core.js"; export type { DatabaseSession, DatabaseUser, Adapter } from "./database.js"; export type { PasswordHashingAlgorithm } from "./crypto.js"; -export type { CookieAttributes } from "oslo/cookie"; import type { Lucia } from "./core.js"; diff --git a/packages/lucia/src/request.ts b/packages/lucia/src/request.ts new file mode 100644 index 000000000..7f48e286f --- /dev/null +++ b/packages/lucia/src/request.ts @@ -0,0 +1,29 @@ +export function verifyRequestOrigin(origin: string, allowedDomains: string[]): boolean { + if (!origin || allowedDomains.length === 0) { + return false; + } + const originHost = safeURL(origin)?.host ?? null; + if (!originHost) { + return false; + } + for (const domain of allowedDomains) { + let host: string | null; + if (domain.startsWith("http://") || domain.startsWith("https://")) { + host = safeURL(domain)?.host ?? null; + } else { + host = safeURL("https://" + domain)?.host ?? null; + } + if (originHost === host) { + return true; + } + } + return false; +} + +function safeURL(url: URL | string): URL | null { + try { + return new URL(url); + } catch { + return null; + } +} diff --git a/packages/lucia/src/scrypt/index.test.ts b/packages/lucia/src/scrypt/index.test.ts index b0e19c5e2..0fcaf5a3f 100644 --- a/packages/lucia/src/scrypt/index.test.ts +++ b/packages/lucia/src/scrypt/index.test.ts @@ -1,12 +1,11 @@ import { expect, test } from "vitest"; import { scrypt } from "./index.js"; import { scryptSync as nodeScrypt } from "node:crypto"; -import { generateRandomString, alphabet } from "oslo/crypto"; -import { encodeHex } from "oslo/encoding"; +import { encodeHexLowerCase } from "@oslojs/encoding"; test("scrypt() output matches crypto", async () => { - const password = generateRandomString(16, alphabet("a-z", "A-Z", "0-9")); - const salt = encodeHex(crypto.getRandomValues(new Uint8Array(16))); + const password = "2uY379HYD&@#Uう2雨h"; + const salt = encodeHexLowerCase(crypto.getRandomValues(new Uint8Array(16))); const scryptHash = await scrypt( new TextEncoder().encode(password), new TextEncoder().encode(salt),