diff --git a/.changeset/mighty-yaks-wave.md b/.changeset/mighty-yaks-wave.md new file mode 100644 index 00000000..bdb9d08e --- /dev/null +++ b/.changeset/mighty-yaks-wave.md @@ -0,0 +1,5 @@ +--- +"@openauthjs/openauth": minor +--- + +Added UnStorage Adapter diff --git a/bun.lockb b/bun.lockb index 7f4b8658..18f06671 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/package.json b/package.json index 5c5606eb..233eddc1 100644 --- a/package.json +++ b/package.json @@ -19,5 +19,8 @@ "prettier": "3.4.2", "typescript": "5.6.3" }, - "private": true + "private": true, + "patchedDependencies": { + "unstorage@1.16.0": "patches/unstorage@1.16.0.patch" + } } diff --git a/packages/openauth/package.json b/packages/openauth/package.json index a5e3276b..64d63b16 100644 --- a/packages/openauth/package.json +++ b/packages/openauth/package.json @@ -37,7 +37,8 @@ "dependencies": { "@standard-schema/spec": "1.0.0-beta.3", "aws4fetch": "1.0.20", - "jose": "5.9.6" + "jose": "5.9.6", + "unstorage": "1.16.0" }, "files": [ "src", diff --git a/packages/openauth/src/storage/unstorage.ts b/packages/openauth/src/storage/unstorage.ts new file mode 100644 index 00000000..70f149f4 --- /dev/null +++ b/packages/openauth/src/storage/unstorage.ts @@ -0,0 +1,101 @@ +/** + * Configure OpenAuth to use unstorage as a store. + * + * This enables you to use any unstorage driver as a store. + * Please refer to [unstorage docs](https://unstorage.unjs.io/drivers) for details on possible drivers. + * + * By default, it uses the memory driver. + * + * :::caution + * The default memory driver is not meant to be used in production. + * ::: + * + * ```ts + * import { UnStorage } from "@openauthjs/openauth/storage/unstorage" + * + * const storage = UnStorage() + * + * export default issuer({ + * storage, + * // ... + * }) + * ``` + * + * Optionally, you can specify a driver. + * + * ```ts + * import fsDriver from "unstorage/drivers/fs"; + * + * UnStorage({ + * driver: fsDriver({ base: "./tmp" }), + * }) + * ``` + * + * @packageDocumentation + */ +import { joinKey, splitKey, StorageAdapter } from "./storage.js" +import { createStorage, type Driver as UnstorageDriver } from "unstorage" + +type Entry = { value: Record | undefined; expiry?: number } + +export function UnStorage({ + driver, +}: { driver?: UnstorageDriver } = {}): StorageAdapter { + const store = createStorage({ + driver: driver, + }) + + return { + async get(key: string[]) { + const k = joinKey(key) + const entry = await store.getItem(k) + + if (!entry) { + return undefined + } + + if (entry.expiry && Date.now() >= entry.expiry) { + await store.removeItem(k) + return undefined + } + + return entry.value + }, + + async set(key: string[], value: any, expiry?: Date) { + const k = joinKey(key) + + await store.setItem(k, { + value, + expiry: expiry ? expiry.getTime() : undefined, + } satisfies Entry) + }, + + async remove(key: string[]) { + const k = joinKey(key) + await store.removeItem(k) + }, + + async *scan(prefix: string[]) { + const now = Date.now() + const prefixStr = joinKey(prefix) + + // Get all keys matching our prefix + const keys = await store.getKeys(prefixStr) + + for (const key of keys) { + // Get the entry for this key + const entry = await store.getItem(key) + + if (!entry) continue + if (entry.expiry && now >= entry.expiry) { + // Clean up expired entries as we go + await store.removeItem(key) + continue + } + + yield [splitKey(key), entry.value] + } + }, + } +} diff --git a/packages/openauth/test/unstorage.test.ts b/packages/openauth/test/unstorage.test.ts new file mode 100644 index 00000000..cd80ae9b --- /dev/null +++ b/packages/openauth/test/unstorage.test.ts @@ -0,0 +1,94 @@ +import { afterEach, setSystemTime } from "bun:test" +import { beforeEach, describe, expect, test } from "bun:test" +import { UnStorage } from "../src/storage/unstorage.js" + +let storage = UnStorage() + +beforeEach(async () => { + storage = UnStorage() + setSystemTime(new Date("1/1/2024")) +}) + +afterEach(() => { + setSystemTime() +}) + +describe("set", () => { + test("basic", async () => { + await storage.set(["users", "123"], { name: "Test User" }) + const result = await storage.get(["users", "123"]) + expect(result).toEqual({ name: "Test User" }) + }) + + test("ttl", async () => { + await storage.set( + ["temp", "key"], + { value: "value" }, + new Date(Date.now() + 100), + ) // 100ms TTL + let result = await storage.get(["temp", "key"]) + expect(result?.value).toBe("value") + + setSystemTime(Date.now() + 150) + result = await storage.get(["temp", "key"]) + expect(result).toBeUndefined() + }) + + test("nested", async () => { + const complexObj = { + id: 1, + nested: { a: 1, b: { c: 2 } }, + array: [1, 2, 3], + } + await storage.set(["complex"], complexObj) + const result = await storage.get(["complex"]) + expect(result).toEqual(complexObj) + }) +}) + +describe("get", () => { + test("missing", async () => { + const result = await storage.get(["nonexistent"]) + expect(result).toBeUndefined() + }) + + test("key", async () => { + await storage.set(["a", "b", "c"], { value: "nested" }) + const result = await storage.get(["a", "b", "c"]) + expect(result?.value).toBe("nested") + }) +}) + +describe("remove", () => { + test("existing", async () => { + await storage.set(["test"], "value") + await storage.remove(["test"]) + const result = await storage.get(["test"]) + expect(result).toBeUndefined() + }) + + test("missing", async () => { + expect(storage.remove(["nonexistent"])).resolves.toBeUndefined() + }) +}) + +describe("scan", () => { + test("all", async () => { + await storage.set(["users", "1"], { id: 1 }) + await storage.set(["users", "2"], { id: 2 }) + await storage.set(["other"], { id: 3 }) + const results = await Array.fromAsync(storage.scan(["users"])) + expect(results).toHaveLength(2) + expect(results).toContainEqual([["users", "1"], { id: 1 }]) + expect(results).toContainEqual([["users", "2"], { id: 2 }]) + }) + + test("ttl", async () => { + await storage.set(["temp", "1"], "a", new Date(Date.now() + 100)) + await storage.set(["temp", "2"], "b", new Date(Date.now() + 100)) + await storage.set(["temp", "3"], "c") + expect(await Array.fromAsync(storage.scan(["temp"]))).toHaveLength(3) + setSystemTime(Date.now() + 150) + expect(await Array.fromAsync(storage.scan(["temp"]))).toHaveLength(1) + }) +}) diff --git a/patches/unstorage@1.16.0.patch b/patches/unstorage@1.16.0.patch new file mode 100644 index 00000000..39cc140a --- /dev/null +++ b/patches/unstorage@1.16.0.patch @@ -0,0 +1,13 @@ +diff --git a/dist/shared/unstorage.CoCt7NXC.mjs b/dist/shared/unstorage.CoCt7NXC.mjs +index bf498f41fce3403ca090ba91bb8d9772e38712f4..100cac0816b227db4e4b314246e2751f08eeeb0e 100644 +--- a/dist/shared/unstorage.CoCt7NXC.mjs ++++ b/dist/shared/unstorage.CoCt7NXC.mjs +@@ -127,7 +127,7 @@ function joinKeys(...keys) { + } + function normalizeBaseKey(base) { + base = normalizeKey(base); +- return base ? base + ":" : ""; ++ return base; + } + function filterKeyByDepth(key, depth) { + if (depth === void 0) { diff --git a/www/astro.config.mjs b/www/astro.config.mjs index d5155da6..fd127bb0 100644 --- a/www/astro.config.mjs +++ b/www/astro.config.mjs @@ -10,7 +10,7 @@ const url = "https://openauth.js.org" // https://astro.build/config export default defineConfig({ site: url, - trailingSlash: 'always', + trailingSlash: "always", devToolbar: { enabled: false, }, @@ -76,10 +76,7 @@ export default defineConfig({ components: { Hero: "./src/components/Hero.astro", }, - customCss: [ - "./src/custom.css", - "./src/styles/lander.css", - ], + customCss: ["./src/custom.css", "./src/styles/lander.css"], sidebar: [ { label: "Intro", slug: "docs" }, { @@ -118,11 +115,21 @@ export default defineConfig({ }, { label: "UI", - items: ["docs/ui/theme", "docs/ui/select", "docs/ui/code", "docs/ui/password"], + items: [ + "docs/ui/theme", + "docs/ui/select", + "docs/ui/code", + "docs/ui/password", + ], }, { label: "Storage", - items: ["docs/storage/memory", "docs/storage/dynamo", "docs/storage/cloudflare"], + items: [ + "docs/storage/memory", + "docs/storage/dynamo", + "docs/storage/cloudflare", + "docs/storage/unstorage", + ], }, ], }), diff --git a/www/src/content/docs/docs/storage/unstorage.mdx b/www/src/content/docs/docs/storage/unstorage.mdx new file mode 100644 index 00000000..c0119b3d --- /dev/null +++ b/www/src/content/docs/docs/storage/unstorage.mdx @@ -0,0 +1,62 @@ +--- +title: UnStorage +editUrl: https://github.com/toolbeam/openauth/blob/master/packages/openauth/src/storage/unstorage.ts +description: Reference doc for the UnStorage storage adapter. +--- + +import { Segment, Section, NestedTitle, InlineSection } from 'toolbeam-docs-theme/components' +import { Tabs, TabItem } from '@astrojs/starlight/components' + +
+
+Configure OpenAuth to use [UnStorage](https://unstorage.unjs.io/) as a storage adapter + + +This enables you to use any UnStorage driver as a store. +Please refer to [UnStorage docs](https://unstorage.unjs.io/drivers) for details on possible drivers. + +By default, it uses the memory driver. + +:::caution +The default memory driver is not meant to be used in production. +::: + +```ts +import { UnStorage } from "@openauthjs/openauth/storage/unstorage" + +const storage = UnStorage() + +export default issuer({ + storage, + // ... +}) +``` + +Optionally, you can specify a driver. + +```ts +import fsDriver from "unstorage/drivers/fs"; + +UnStorage({ + driver: fsDriver({ base: "./tmp" }), +}) +``` +
+--- +## Methods +### MemoryStorage + +
+```ts +UnStorage( { driver?: UnstorageDriver } ) +``` +
+
+#### Parameters +-

driver? [UnStorage Driver](https://unstorage.unjs.io/drivers)

+
+ +**Returns** StorageAdapter + +
+