From 2627876f6a2f5e65f496c9c461cc671acba188f0 Mon Sep 17 00:00:00 2001 From: Hernan Alvarado Date: Wed, 26 Aug 2026 18:33:36 -0500 Subject: [PATCH 1/3] feat(rate-limiter): support context values in function operations --- packages/rate-limiter/CHANGELOG.md | 10 ++ packages/rate-limiter/package.json | 6 +- .../src/algorithms/fixed-window.ts | 30 ++++-- .../src/algorithms/leaky-bucket.ts | 29 ++++-- .../src/algorithms/sliding-window.ts | 31 ++++-- .../src/algorithms/token-bucket.ts | 29 ++++-- packages/rate-limiter/src/index.ts | 7 +- packages/rate-limiter/src/rate-limiter.ts | 71 ++++--------- packages/rate-limiter/src/types.ts | 69 ++++++++----- .../test/algorithms/fixed-window.test.ts | 69 +++++++++++++ .../test/algorithms/leaky-bucket.test.ts | 91 +++++++++++++++++ .../test/algorithms/sliding-window.test.ts | 73 +++++++++++++- .../test/algorithms/token-bucket.test.ts | 99 +++++++++++++++++++ packages/rate-limiter/vitest.config.ts | 2 +- 14 files changed, 497 insertions(+), 119 deletions(-) diff --git a/packages/rate-limiter/CHANGELOG.md b/packages/rate-limiter/CHANGELOG.md index e712c335..b46d0d11 100644 --- a/packages/rate-limiter/CHANGELOG.md +++ b/packages/rate-limiter/CHANGELOG.md @@ -8,6 +8,16 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ## [Unreleased] +### Added + +- Added support for passing context values to the `check()`, `peek()`, and `reset()` functions. The context can provide additional information for generating or validating rate-limit keys, particularly when a key is derived from external resources rather than directly from the request. [#269](https://github.com/aura-stack-ts/auth/pull/269) + +- Added asynchronous support for the `keyGenerator` callback used to generate rate-limiter keys. The callback can now return either a synchronous value or a `Promise`, allowing asynchronous logic to be used when generating the key tracked by the rate limiter. [#269](https://github.com/aura-stack-ts/auth/pull/269) + +### Fixed + +- Fixed the dedicated `/algorithms/:algorithm` entry points, which now correctly resolve and expose their respective algorithm modules. [#269](https://github.com/aura-stack-ts/auth/pull/269) + --- ## [0.1.1] - 2026-07-04 diff --git a/packages/rate-limiter/package.json b/packages/rate-limiter/package.json index 23f2be91..d01217a1 100644 --- a/packages/rate-limiter/package.json +++ b/packages/rate-limiter/package.json @@ -76,9 +76,9 @@ "require": "./dist/algorithms/index.cjs" }, "./algorithms/*": { - "types": "./dist/algorithms/*/index.d.ts", - "import": "./dist/algorithms/*/index.js", - "require": "./dist/algorithms/*/index.cjs" + "types": "./dist/algorithms/*.d.ts", + "import": "./dist/algorithms/*.js", + "require": "./dist/algorithms/*.cjs" }, "./types": "./dist/types.d.ts" }, diff --git a/packages/rate-limiter/src/algorithms/fixed-window.ts b/packages/rate-limiter/src/algorithms/fixed-window.ts index 63f40cbd..d1154a49 100644 --- a/packages/rate-limiter/src/algorithms/fixed-window.ts +++ b/packages/rate-limiter/src/algorithms/fixed-window.ts @@ -18,19 +18,25 @@ import type { FixedWindowRule, RateLimiterAlgorithm, RateLimitResult } from "@/t * Recommended for: coarse-grained public API quotas where a boundary burst * is acceptable, or anywhere you want the simplest possible semantics. */ -export const createFixedWindowAlgorithm = ( - rule: FixedWindowRule -): RateLimiterAlgorithm => { - const { limit, windowMs, storage = createMemoryStorage() } = rule +export const createFixedWindowAlgorithm = ( + rule: FixedWindowRule +): RateLimiterAlgorithm => { + const { limit, windowMs } = rule + const storage = rule.storage ?? createMemoryStorage() const boundary = (now: number) => Math.floor(now / windowMs) * windowMs const windowKey = (baseKey: string, now: number) => `${baseKey}:fw:${boundary(now)}` const resetAt = (now: number) => boundary(now) + windowMs - const check = async (request: RequestInit): Promise => { + const resolveKey = (request: RequestInit, context?: Context): string | Promise => + context !== undefined + ? (rule.keyGenerator as (r: RequestInit, c: Context) => string | Promise)(request, context) + : (rule.keyGenerator as (r: RequestInit) => string | Promise)(request) + + const check = async (request: RequestInit, context?: Context): Promise => { const now = Date.now() const reset = resetAt(now) - const key = rule.keyGenerator(request) + const key = await resolveKey(request, context) const count = await storage.increment(windowKey(key, now), windowMs) const ok = count <= limit @@ -43,10 +49,10 @@ export const createFixedWindowAlgorithm = ( }) } - const peek = async (request: RequestInit): Promise => { + const peek = async (request: RequestInit, context?: Context): Promise => { const now = Date.now() const reset = resetAt(now) - const key = rule.keyGenerator(request) + const key = await resolveKey(request, context) const entry = await storage.get(windowKey(key, now)) const count = entry?.value ?? 0 const ok = count < limit @@ -60,5 +66,11 @@ export const createFixedWindowAlgorithm = ( }) } - return { check, peek } + const reset = async (request: RequestInit, context?: Context): Promise => { + const now = Date.now() + const key = await resolveKey(request, context) + await storage.delete(windowKey(key, now)) + } + + return { check, peek, reset } as RateLimiterAlgorithm } diff --git a/packages/rate-limiter/src/algorithms/leaky-bucket.ts b/packages/rate-limiter/src/algorithms/leaky-bucket.ts index e3002561..4d7a0342 100644 --- a/packages/rate-limiter/src/algorithms/leaky-bucket.ts +++ b/packages/rate-limiter/src/algorithms/leaky-bucket.ts @@ -23,10 +23,11 @@ interface BucketState { * * Recommended for: outbound webhook dispatch, downstream call smoothing. */ -export const createLeakyBucketAlgorithm = ( - rule: LeakyBucketRule -): RateLimiterAlgorithm => { - const { capacity, leakRatePerMs, storage = createMemoryStorage() } = rule +export const createLeakyBucketAlgorithm = ( + rule: LeakyBucketRule +): RateLimiterAlgorithm => { + const { capacity, leakRatePerMs } = rule + const storage = rule.storage ?? createMemoryStorage() const levelKey = (key: string) => `${key}:lb:level` const lastLeakKey = (key: string) => `${key}:lb:lastLeak` @@ -53,9 +54,14 @@ export const createLeakyBucketAlgorithm = ( ]) } - const check = async (request: RequestInit): Promise => { + const resolveKey = (request: RequestInit, context?: Context): string | Promise => + context !== undefined + ? (rule.keyGenerator as (r: RequestInit, c: Context) => string | Promise)(request, context) + : (rule.keyGenerator as (r: RequestInit) => string | Promise)(request) + + const check = async (request: RequestInit, context?: Context): Promise => { const now = Date.now() - const key = rule.keyGenerator(request) + const key = await resolveKey(request, context) const stored = await readBucket(key, now) const level = getCurrentLevel(stored, now) const newLevel = level + 1 @@ -76,9 +82,9 @@ export const createLeakyBucketAlgorithm = ( }) } - const peek = async (request: RequestInit): Promise => { + const peek = async (request: RequestInit, context?: Context): Promise => { const now = Date.now() - const key = rule.keyGenerator(request) + const key = await resolveKey(request, context) const stored = await readBucket(key, now) const level = getCurrentLevel(stored, now) const nextLevel = level + 1 @@ -94,5 +100,10 @@ export const createLeakyBucketAlgorithm = ( }) } - return { check, peek } + const reset = async (request: RequestInit, context?: Context): Promise => { + const key = await resolveKey(request, context) + await Promise.all([storage.delete(levelKey(key)), storage.delete(lastLeakKey(key))]) + } + + return { check, peek, reset } as RateLimiterAlgorithm } diff --git a/packages/rate-limiter/src/algorithms/sliding-window.ts b/packages/rate-limiter/src/algorithms/sliding-window.ts index 9a456277..54a918ad 100644 --- a/packages/rate-limiter/src/algorithms/sliding-window.ts +++ b/packages/rate-limiter/src/algorithms/sliding-window.ts @@ -16,10 +16,11 @@ import type { RateLimiterAlgorithm, RateLimitResult, SlidingWindowRule } from "@ * * Recommended for: security-sensitive endpoints (signIn, signOut, verifyToken). */ -export const createSlidingWindowAlgorithm = ( - rule: SlidingWindowRule -): RateLimiterAlgorithm => { - const { limit, windowMs, storage = createMemoryStorage() } = rule +export const createSlidingWindowAlgorithm = ( + rule: SlidingWindowRule +): RateLimiterAlgorithm => { + const { limit, windowMs } = rule + const storage = rule.storage ?? createMemoryStorage() const getBoundary = (now: number) => Math.floor(now / windowMs) * windowMs @@ -31,6 +32,11 @@ export const createSlidingWindowAlgorithm = ( } } + const resolveKey = (request: RequestInit, context?: Context): string | Promise => + context !== undefined + ? (rule.keyGenerator as (r: RequestInit, c: Context) => string | Promise)(request, context) + : (rule.keyGenerator as (r: RequestInit) => string | Promise)(request) + const estimate = async (baseKey: string, now: number): Promise<{ count: number; resetAt: number }> => { const boundary = getBoundary(now) const weight = (now - boundary) / windowMs @@ -42,11 +48,11 @@ export const createSlidingWindowAlgorithm = ( return { count, resetAt: boundary + windowMs } } - const check = async (request: RequestInit): Promise => { + const check = async (request: RequestInit, context?: Context): Promise => { const now = Date.now() const boundary = getBoundary(now) const reset = boundary + windowMs - const key = rule.keyGenerator(request) + const key = await resolveKey(request, context) const { current, previous } = windowKeys(key, now) const newCount = await storage.increment(current, windowMs * 2) @@ -64,9 +70,9 @@ export const createSlidingWindowAlgorithm = ( }) } - const peek = async (request: RequestInit): Promise => { + const peek = async (request: RequestInit, context?: Context): Promise => { + const key = await resolveKey(request, context) const now = Date.now() - const key = rule.keyGenerator(request) const { count, resetAt } = await estimate(key, now) const ok = count <= limit @@ -79,5 +85,12 @@ export const createSlidingWindowAlgorithm = ( }) } - return { check, peek } + const reset = async (request: RequestInit, context?: Context): Promise => { + const now = Date.now() + const key = await resolveKey(request, context) + const { current, previous } = windowKeys(key, now) + await Promise.all([storage.delete(current), storage.delete(previous)]) + } + + return { check, peek, reset } as RateLimiterAlgorithm } diff --git a/packages/rate-limiter/src/algorithms/token-bucket.ts b/packages/rate-limiter/src/algorithms/token-bucket.ts index 37e3ce08..7a2745a9 100644 --- a/packages/rate-limiter/src/algorithms/token-bucket.ts +++ b/packages/rate-limiter/src/algorithms/token-bucket.ts @@ -21,10 +21,11 @@ interface BucketState { lastRefillAt: number } -export const createTokenBucketAlgorithm = ( - rule: TokenBucketRule -): RateLimiterAlgorithm => { - const { capacity, refillRate, storage = createMemoryStorage() } = rule +export const createTokenBucketAlgorithm = ( + rule: TokenBucketRule +): RateLimiterAlgorithm => { + const { capacity, refillRate } = rule + const storage = rule.storage ?? createMemoryStorage() if (!Number.isFinite(capacity) || capacity <= 0) { throw new Error(`[rate-limiter] Invalid token-bucket capacity: ${capacity}`) @@ -60,9 +61,14 @@ export const createTokenBucketAlgorithm = ( await Promise.all([storage.set(tokensKey(key), tokensEntry, ttl), storage.set(lastRefillKey(key), refillEntry, ttl)]) } - const check = async (request: RequestInit): Promise => { + const resolveKey = (request: RequestInit, context?: Context): string | Promise => + context !== undefined + ? (rule.keyGenerator as (r: RequestInit, c: Context) => string | Promise)(request, context) + : (rule.keyGenerator as (r: RequestInit) => string | Promise)(request) + + const check = async (request: RequestInit, context?: Context): Promise => { const now = Date.now() - const key = rule.keyGenerator(request) + const key = await resolveKey(request, context) const state = await getBucketState(key, now) const tokens = refill(state.tokens, state.lastRefillAt, now) const ok = tokens >= 1 @@ -81,9 +87,9 @@ export const createTokenBucketAlgorithm = ( }) } - const peek = async (request: RequestInit): Promise => { + const peek = async (request: RequestInit, context?: Context): Promise => { const now = Date.now() - const key = rule.keyGenerator(request) + const key = await resolveKey(request, context) const stored = await getBucketState(key, now) const currentTokens = refill(stored.tokens, stored.lastRefillAt, now) const ok = currentTokens >= 1 @@ -99,5 +105,10 @@ export const createTokenBucketAlgorithm = ( }) } - return { check, peek } + const reset = async (request: RequestInit, context?: Context): Promise => { + const key = await resolveKey(request, context) + await Promise.all([storage.delete(tokensKey(key)), storage.delete(lastRefillKey(key))]) + } + + return { check, peek, reset } as RateLimiterAlgorithm } diff --git a/packages/rate-limiter/src/index.ts b/packages/rate-limiter/src/index.ts index 69086664..ea5a07c3 100644 --- a/packages/rate-limiter/src/index.ts +++ b/packages/rate-limiter/src/index.ts @@ -1,4 +1,9 @@ export type * from "@/types.ts" export { createMemoryStorage } from "@/memory.ts" export { createRateLimiter } from "@/rate-limiter.ts" -export { createTokenBucketAlgorithm } from "@/algorithms/index.ts" +export { + createTokenBucketAlgorithm, + createFixedWindowAlgorithm, + createLeakyBucketAlgorithm, + createSlidingWindowAlgorithm, +} from "@/algorithms/index.ts" diff --git a/packages/rate-limiter/src/rate-limiter.ts b/packages/rate-limiter/src/rate-limiter.ts index b3767e67..6dae7704 100644 --- a/packages/rate-limiter/src/rate-limiter.ts +++ b/packages/rate-limiter/src/rate-limiter.ts @@ -5,69 +5,29 @@ import { createLeakyBucketAlgorithm, createSlidingWindowAlgorithm, } from "@/algorithms/index.ts" -import type { InferRules, RateLimiter, RateLimiterAlgorithm, RateLimiterConfig, RateLimiterRule } from "@/types.ts" +import type { InferRules, RateLimiterConfig, RateLimiterRule } from "@/types.ts" /** * Builds the algorithm instance for a rule, memoized per endpoint name. */ -const buildAlgorithm = (rule: RateLimiterRule): RateLimiterAlgorithm => { +const buildAlgorithm = ( + rule: RateLimiterRule +): InferRules>[string] => { rule.algorithm ||= "token-bucket" switch (rule.algorithm) { case "token-bucket": - return createTokenBucketAlgorithm(rule) + return createTokenBucketAlgorithm(rule) as InferRules>[string] case "fixed-window": - return createFixedWindowAlgorithm(rule) + return createFixedWindowAlgorithm(rule) as InferRules>[string] case "leaky-bucket": - return createLeakyBucketAlgorithm(rule) + return createLeakyBucketAlgorithm(rule) as InferRules>[string] case "sliding-window": - return createSlidingWindowAlgorithm(rule) - default: { + return createSlidingWindowAlgorithm(rule) as InferRules>[string] + default: throw new Error(`[rate-limiter] Unknown algorithm: "${String((rule as { algorithm?: string }).algorithm)}"`) - } } } -const resetKeys = (rule: RateLimiterRule, key: string): string[] => { - switch (rule.algorithm) { - case "token-bucket": - return [`${key}:tb:tokens`, `${key}:tb:lastRefill`] - case "fixed-window": - return [`${key}:fw`] - case "leaky-bucket": - return [`${key}:lb:tokens`, `${key}:lb:lastLeak`] - case "sliding-window": - const boundary = Math.floor(Date.now() / rule.windowMs) * rule.windowMs - return [`${key}:sw:${boundary}`, `${key}:sw:${boundary - rule.windowMs}`] - } -} - -const buildHandle = ( - rule: RateLimiterRule, - algorithm: RateLimiterAlgorithm, - config: RateLimiterConfig> -): RateLimiter => { - const { storage } = config - - const resolveKey = (request: RequestInit | string): string => { - return typeof request === "string" ? request : rule.keyGenerator(request) - } - - const check = (request: RequestInit) => { - return algorithm.check(request) - } - - const peek = (request: RequestInit) => { - return algorithm.peek(request) - } - - const reset = async (request: RequestInit | string): Promise => { - const key = resolveKey(request) - await Promise.all(resetKeys(rule as RateLimiterRule, key).map((k) => storage!.delete(k))) - } - - return { check, peek, reset } -} - /** * Creates a fully configured, runtime-agnostic rate limiter. * @@ -90,14 +50,17 @@ const buildHandle = ( * * ``` */ -export const createRateLimiter = >( +export const createRateLimiter = < + Rules extends Record> = Record>, +>( config: RateLimiterConfig ): InferRules => { - config.storage ||= createMemoryStorage() + const globalStorage = config.storage ?? createMemoryStorage() const handlers = {} as InferRules - for (const [rule, ruleConfig] of Object.entries(config.rules)) { - const algorithm = buildAlgorithm(ruleConfig) - handlers[rule as keyof Rules] = buildHandle(ruleConfig, algorithm, config) as InferRules[keyof Rules] + for (const [name, rule] of Object.entries(config.rules)) { + const algorithm = buildAlgorithm(rule) as InferRules[keyof Rules] + rule.storage ??= globalStorage + handlers[name as keyof Rules] = algorithm } return handlers } diff --git a/packages/rate-limiter/src/types.ts b/packages/rate-limiter/src/types.ts index c46e74d7..ba36add7 100644 --- a/packages/rate-limiter/src/types.ts +++ b/packages/rate-limiter/src/types.ts @@ -53,14 +53,24 @@ export interface RateLimitResult { toResponse(): Response } -export interface RateLimiterAlgorithm { - peek(request: RequestInit): Promise - check(request: RequestInit): Promise +export interface RateLimiterAlgorithm { + peek: [Context] extends [never] + ? (request: RequestInit) => RateLimitResult | Promise + : (request: RequestInit, context: Context) => RateLimitResult | Promise + check: [Context] extends [never] + ? (request: RequestInit) => RateLimitResult | Promise + : (request: RequestInit, context: Context) => RateLimitResult | Promise + reset: [Context] extends [never] + ? (request: RequestInit) => Promise + : (request: RequestInit, context: Context) => Promise } - export type AlgorithmType = "token-bucket" | "fixed-window" | "leaky-bucket" | "sliding-window" -interface BaseRule { +export type KeyGenerator = [Context] extends [never] + ? (request: RequestInit) => string | Promise + : (request: RequestInit, context: Context) => string | Promise + +interface BaseRule { algorithm: AlgorithmType /** * Derives the storage key from the incoming request. @@ -68,14 +78,15 @@ interface BaseRule { * * @example (req) => `${req.ip}:${req.path}` */ - keyGenerator: (request: RequestInit) => string + keyGenerator: KeyGenerator + /** * Optional storage instance specific to this rule. */ storage?: RateLimiterStorage } -export type TokenBucketRule = BaseRule & { +export type TokenBucketRule = BaseRule & { algorithm?: "token-bucket" /** Maximum token capacity (burst ceiling). */ capacity: number @@ -83,7 +94,7 @@ export type TokenBucketRule = BaseRule & { refillRate: number } -export interface FixedWindowRule extends BaseRule { +export interface FixedWindowRule extends BaseRule { algorithm: "fixed-window" /** Maximum requests allowed per window. */ limit: number @@ -91,7 +102,7 @@ export interface FixedWindowRule extends BaseRule extends BaseRule { +export interface LeakyBucketRule extends BaseRule { algorithm: "leaky-bucket" /** * The maximum queue size (burst capacity). When the bucket is full, @@ -106,7 +117,7 @@ export interface LeakyBucketRule extends BaseRule extends BaseRule { +export interface SlidingWindowRule extends BaseRule { algorithm: "sliding-window" /** Maximum requests allowed per window. */ limit: number @@ -114,13 +125,13 @@ export interface SlidingWindowRule extends BaseRule = - | TokenBucketRule - | FixedWindowRule - | LeakyBucketRule - | SlidingWindowRule +export type RateLimiterRule = + | TokenBucketRule + | FixedWindowRule + | LeakyBucketRule + | SlidingWindowRule -export interface RateLimiterConfig> { +export interface RateLimiterConfig>> { storage?: RateLimiterStorage /** * Per-endpoint rules, keyed by an arbitrary route/action name that you pass @@ -129,24 +140,38 @@ export interface RateLimiterConfig rules: Rules } -export interface RateLimiter { +export interface RateLimiter { /** * Checks `key` against the rule registered for `endpoint`. * Returns the result and calls `onRejected` when the request is blocked. */ - check(request: RequestInit): Promise + check: [Context] extends [never] + ? (request: RequestInit) => Promise + : (request: RequestInit, context: Context) => Promise /** * Resets the counter/bucket for `key` on the given `endpoint`. * Useful after a successful login to clear failed-attempt counters. */ - reset(request: RequestInit): Promise + reset: [Context] extends [never] + ? (request: RequestInit) => Promise + : (request: RequestInit, context: Context) => Promise /** * Returns the current state without mutating any counters. * Useful for surfacing limit headers on every response, not just limited ones. */ - peek(request: RequestInit): Promise + peek: [Context] extends [never] + ? (request: RequestInit) => Promise + : (request: RequestInit, context: Context) => Promise } -export type InferRules> = { - [K in keyof TRules]: TRules[K] extends RateLimiterRule ? RateLimiter : never +export type InferContext> = T["keyGenerator"] extends (request: any) => any + ? never + : T["keyGenerator"] extends (request: any, context: infer C) => any + ? C + : never + +export type InferRules>> = { + [K in keyof TRules]: TRules[K] extends RateLimiterRule + ? RateLimiter> + : never } diff --git a/packages/rate-limiter/test/algorithms/fixed-window.test.ts b/packages/rate-limiter/test/algorithms/fixed-window.test.ts index 4e24c3a6..6a17751a 100644 --- a/packages/rate-limiter/test/algorithms/fixed-window.test.ts +++ b/packages/rate-limiter/test/algorithms/fixed-window.test.ts @@ -18,6 +18,16 @@ const createAlgorithm = (limit = 2, windowMs = 1000) => { }) } +const createAlgorithmWithContext = (limit = 3, windowMs = 10000) => { + return createFixedWindowAlgorithm({ + algorithm: "fixed-window", + limit, + windowMs, + storage: createMemoryStorage(), + keyGenerator: (req, ctx) => `account:${req.key}:${ctx.clientId}`, + }) +} + beforeEach(() => { vi.useFakeTimers() vi.setSystemTime(0) @@ -115,4 +125,63 @@ describe("FixedWindowAlgorithm", () => { expect(second.remaining).toBe(0) expect(second.resetAt).toBe(2000) }) + + test("peek does not mutate and uses context key", async () => { + const limiter = createAlgorithmWithContext() + + const result = await limiter.peek(request("ip:1"), { clientId: "123" }) + expect(result).toMatchObject({ ok: true, limit: 3, remaining: 3, retryAfter: 0 }) + }) + + test("check keys by context — different usernames are independent", async () => { + const limiter = createAlgorithmWithContext() + const req = request("ip:1") + + await limiter.check(req, { clientId: "123" }) + await limiter.check(req, { clientId: "123" }) + await limiter.check(req, { clientId: "123" }) + const aliceBlocked = await limiter.check(req, { clientId: "123" }) + + const bobAllowed = await limiter.check(req, { clientId: "987" }) + + expect(aliceBlocked.ok).toBe(false) + expect(bobAllowed.ok).toBe(true) + }) + + test("reset via context clears only that user's counter", async () => { + const limiter = createAlgorithmWithContext() + const req = request("ip:1") + + await limiter.check(req, { clientId: "123" }) + await limiter.check(req, { clientId: "123" }) + await limiter.check(req, { clientId: "123" }) + expect((await limiter.check(req, { clientId: "123" })).ok).toBe(false) + + await limiter.reset(req, { clientId: "123" }) + + expect((await limiter.check(req, { clientId: "123" })).ok).toBe(true) + expect((await limiter.check(req, { clientId: "987" })).ok).toBe(true) + }) + + test("async keyGenerator resolves correctly", async () => { + const storage = createMemoryStorage() + const limiter = createFixedWindowAlgorithm({ + algorithm: "fixed-window", + limit: 2, + windowMs: 10000, + storage, + keyGenerator: async (_req, { clientId }) => { + await Promise.resolve() + return `account:${clientId.toLowerCase()}` + }, + }) + + const r1 = await limiter.check(request("ip:1"), { clientId: "123" }) + const r2 = await limiter.check(request("ip:1"), { clientId: "123" }) + const r3 = await limiter.check(request("ip:1"), { clientId: "123" }) + + expect(r1.ok).toBe(true) + expect(r2.ok).toBe(true) + expect(r3.ok).toBe(false) + }) }) diff --git a/packages/rate-limiter/test/algorithms/leaky-bucket.test.ts b/packages/rate-limiter/test/algorithms/leaky-bucket.test.ts index 6432a228..289143b9 100644 --- a/packages/rate-limiter/test/algorithms/leaky-bucket.test.ts +++ b/packages/rate-limiter/test/algorithms/leaky-bucket.test.ts @@ -6,6 +6,10 @@ interface TestRequest { key: string } +interface SessionContext { + clientId: string +} + const request = (key: string): TestRequest => ({ key }) const createAlgorithm = (capacity = 2, leakRatePerMs = 1) => { @@ -18,6 +22,16 @@ const createAlgorithm = (capacity = 2, leakRatePerMs = 1) => { }) } +const createAlgorithmWithContext = (capacity = 2, leakRatePerMs = 1) => { + return createLeakyBucketAlgorithm({ + algorithm: "leaky-bucket", + capacity, + leakRatePerMs, + storage: createMemoryStorage(), + keyGenerator: (req, ctx) => `lb:${req.key}:${ctx.clientId}`, + }) +} + beforeEach(() => { vi.useFakeTimers() vi.setSystemTime(0) @@ -132,3 +146,80 @@ describe("LeakyBucketAlgorithm", () => { expect(afterExpiry.resetAt).toBe(5001) }) }) + +describe("LeakyBucketAlgorithm with context", () => { + const capacity = 3 + const leakRatePerMs = 1 / 1_000_000 + + test("different users have independent buckets", async () => { + const limiter = createAlgorithmWithContext(capacity, leakRatePerMs) + const req = request("ip") + + for (let i = 0; i < capacity; i++) await limiter.check(req, { clientId: "alice" }) + const aliceBlocked = await limiter.check(req, { clientId: "alice" }) + + const bobAllowed = await limiter.check(req, { clientId: "bob" }) + + expect(aliceBlocked.ok).toBe(false) + expect(bobAllowed.ok).toBe(true) + }) + + test("rejected requests do not raise level for context-keyed bucket", async () => { + const limiter = createAlgorithmWithContext(capacity, leakRatePerMs) + const req = request("ip:1") + const ctx: SessionContext = { clientId: "carol" } + + for (let i = 0; i < capacity; i++) await limiter.check(req, ctx) + + for (let i = 0; i < 5; i++) { + const result = await limiter.check(req, ctx) + expect(result.ok).toBe(false) + expect(result.remaining).toBe(0) + } + }) + + test("peek with context does not fill the bucket", async () => { + const limiter = createAlgorithmWithContext(capacity, leakRatePerMs) + const req = request("ip:1") + const ctx: SessionContext = { clientId: "dave" } + + await limiter.peek(req, ctx) + await limiter.peek(req, ctx) + + const r1 = await limiter.check(req, ctx) + expect(r1.ok).toBe(true) + expect(r1.remaining).toBe(capacity - 1) + }) + + test("reset with context drains only that user's bucket", async () => { + const limiter = createAlgorithmWithContext(capacity, leakRatePerMs) + const req = request("ip:1") + + for (let i = 0; i < capacity; i++) await limiter.check(req, { clientId: "eve" }) + expect((await limiter.check(req, { clientId: "eve" })).ok).toBe(false) + + await limiter.reset(req, { clientId: "eve" }) + expect((await limiter.check(req, { clientId: "eve" })).ok).toBe(true) + + const frankResult = await limiter.check(req, { clientId: "frank" }) + expect(frankResult.ok).toBe(true) + }) + + test("retryAfter reflects leak rate when blocked", async () => { + const storage = createMemoryStorage() + const leakRatePerMsKnown = 1 / 200 + const limiter = createLeakyBucketAlgorithm({ + algorithm: "leaky-bucket", + capacity: 1, + leakRatePerMs: leakRatePerMsKnown, + storage, + keyGenerator: (_req, { clientId }) => `lb:retry:${clientId}`, + }) + + await limiter.check(request("ip:1"), { clientId: "grace" }) + const blocked = await limiter.check(request("ip:1"), { clientId: "grace" }) + + expect(blocked.ok).toBe(false) + expect(blocked.retryAfter).toBe(Math.ceil(1 / leakRatePerMsKnown)) + }) +}) diff --git a/packages/rate-limiter/test/algorithms/sliding-window.test.ts b/packages/rate-limiter/test/algorithms/sliding-window.test.ts index d1c605ec..c19cee7d 100644 --- a/packages/rate-limiter/test/algorithms/sliding-window.test.ts +++ b/packages/rate-limiter/test/algorithms/sliding-window.test.ts @@ -1,5 +1,4 @@ import { afterEach, beforeEach, describe, expect, test, vi } from "vitest" - import { createMemoryStorage } from "@/memory.ts" import { createSlidingWindowAlgorithm } from "@/algorithms/sliding-window.ts" @@ -29,7 +28,7 @@ describe("createSlidingWindowAlgorithm", () => { limit, windowMs, storage, - keyGenerator: () => "user", + keyGenerator: (_) => "user", }) const result = await limiter.peek(request) @@ -284,3 +283,73 @@ describe("createSlidingWindowAlgorithm", () => { }) }) }) + +describe("createSlidingWindowAlgorithm with context", () => { + const limit = 4 + const windowMs = 15_000 + + interface TestRequest { + key: string + } + + const request = (key: string): TestRequest => ({ key }) + + const createAlgorithm = () => { + return createSlidingWindowAlgorithm({ + algorithm: "sliding-window", + limit, + windowMs, + storage: createMemoryStorage(), + keyGenerator: (_req, { clientId }) => `sw:account:${clientId}`, + }) + } + + test("different users have independent counters", async () => { + const limiter = createAlgorithm() + const req = request("ip:1") + + for (let i = 0; i < limit; i++) await limiter.check(req, { clientId: "alice" }) + const aliceBlocked = await limiter.check(req, { clientId: "alice" }) + + const bobResult = await limiter.check(req, { clientId: "bob" }) + + expect(aliceBlocked.ok).toBe(false) + expect(bobResult.ok).toBe(true) + }) + + test("peek with context does not consume quota", async () => { + const limiter = createAlgorithm() + const req = request("ip:1") + + await limiter.peek(req, { clientId: "carol" }) + await limiter.peek(req, { clientId: "carol" }) + + const after = await limiter.check(req, { clientId: "carol" }) + expect(after.remaining).toBe(limit - 1) + }) + + test("reset with context only clears that user", async () => { + const limiter = createAlgorithm() + const req = request("ip:1") + + for (let i = 0; i < limit; i++) await limiter.check(req, { clientId: "dave" }) + expect((await limiter.check(req, { clientId: "dave" })).ok).toBe(false) + + await limiter.reset(req, { clientId: "dave" }) + expect((await limiter.check(req, { clientId: "dave" })).ok).toBe(true) + + expect((await limiter.check(req, { clientId: "eve" })).ok).toBe(true) + }) + + test("retryAfter is 0 when ok, positive when blocked", async () => { + const limiter = createAlgorithm() + const req = request("ip:1") + + const ok = await limiter.check(req, { clientId: "frank" }) + expect(ok.retryAfter).toBe(0) + + for (let i = 1; i < limit; i++) await limiter.check(req, { clientId: "frank" }) + const blocked = await limiter.check(req, { clientId: "frank" }) + expect(blocked.retryAfter).toBeGreaterThan(0) + }) +}) diff --git a/packages/rate-limiter/test/algorithms/token-bucket.test.ts b/packages/rate-limiter/test/algorithms/token-bucket.test.ts index 6f975e06..0d88f7d9 100644 --- a/packages/rate-limiter/test/algorithms/token-bucket.test.ts +++ b/packages/rate-limiter/test/algorithms/token-bucket.test.ts @@ -7,6 +7,10 @@ interface TestRequest { key: string } +interface SessionContext { + clientId: string +} + const request = (key: string): TestRequest => ({ key }) const httpRequest = (key: string): Request => new Request("https://example.test/login", { headers: { "x-forwarded-for": key } }) @@ -20,6 +24,16 @@ const createAlgorithm = (capacity = 2, refillRate = 0.01) => { }) } +const createAlgorithmWithContext = (capacity = 4, refillRate = 1 / 1_000_000) => { + return createTokenBucketAlgorithm({ + algorithm: "token-bucket", + capacity, + refillRate, + storage: createMemoryStorage(), + keyGenerator: (req, ctx) => `tb:${ctx.clientId}:${req.key}`, + }) +} + describe("TokenBucketAlgorithm", () => { beforeEach(() => { vi.useFakeTimers() @@ -98,6 +112,91 @@ describe("TokenBucketAlgorithm", () => { }) }) +describe("TokenBucketAlgorithm with context", () => { + const capacity = 4 + const refillRate = 1 / 1_000_000 + + test("different sessions have independent buckets", async () => { + const limiter = createAlgorithmWithContext(capacity, refillRate) + const req = request("ip:1") + + const ctxA: SessionContext = { clientId: "clientA" } + const ctxB: SessionContext = { clientId: "clientB" } as any + + for (let i = 0; i < capacity; i++) await limiter.check(req, ctxA) + const aBlocked = await limiter.check(req, ctxA) + + const bAllowed = await limiter.check(req, ctxB) + + expect(aBlocked.ok).toBe(false) + expect(bAllowed.ok).toBe(true) + }) + + test("same user, same session shares a bucket", async () => { + const limiter = createAlgorithmWithContext(capacity, refillRate) + const req = request("ip:1") + const ctx: SessionContext = { clientId: "clientC" } + + const r1 = await limiter.check(req, ctx) + const r2 = await limiter.check(req, ctx) + + expect(r1.remaining).toBe(3) + expect(r2.remaining).toBe(2) + }) + + test("peek with context reads without draining", async () => { + const limiter = createAlgorithmWithContext(capacity, refillRate) + const req = request("ip:1") + const ctx: SessionContext = { clientId: "u3" } + + await limiter.peek(req, ctx) + await limiter.peek(req, ctx) + + const after = await limiter.check(req, ctx) + expect(after.remaining).toBe(capacity - 1) + }) + + test("reset with context replenishes only that session", async () => { + const limiter = createAlgorithmWithContext(capacity, refillRate) + const req = request("ip:1") + const ctxA: SessionContext = { clientId: "u4" } + const ctxB: SessionContext = { clientId: "u5" } + + for (let i = 0; i < capacity; i++) await limiter.check(req, ctxA) + expect((await limiter.check(req, ctxA)).ok).toBe(false) + + await limiter.reset(req, ctxA) + expect((await limiter.check(req, ctxA)).ok).toBe(true) + + const bResult = await limiter.check(req, ctxB) + expect(bResult.ok).toBe(true) + expect(bResult.remaining).toBe(capacity - 1) + }) + + test("async keyGenerator resolves correctly with context", async () => { + const storage = createMemoryStorage() + const limiter = createTokenBucketAlgorithm({ + algorithm: "token-bucket", + capacity: 2, + refillRate, + storage, + keyGenerator: async (_req, { clientId }) => { + await Promise.resolve() + return `tb:${clientId}` + }, + }) + + const ctx: SessionContext = { clientId: "u5" } + const r1 = await limiter.check(request("ip:1"), ctx) + const r2 = await limiter.check(request("ip:1"), ctx) + const r3 = await limiter.check(request("ip:1"), ctx) + + expect(r1.ok).toBe(true) + expect(r2.ok).toBe(true) + expect(r3.ok).toBe(false) + }) +}) + describe("createRateLimiter", () => { test("builds endpoint handlers that enforce independent limits", async () => { const authStorage = createMemoryStorage() diff --git a/packages/rate-limiter/vitest.config.ts b/packages/rate-limiter/vitest.config.ts index cf3fa051..db771ec3 100644 --- a/packages/rate-limiter/vitest.config.ts +++ b/packages/rate-limiter/vitest.config.ts @@ -11,7 +11,7 @@ export default defineConfig({ }, resolve: { alias: { - "@": path.resolve(__dirname, "./src"), + "@": path.resolve(import.meta.dirname, "./src"), }, }, }) From b12869a28e71bc09d9fa49fcb910bb4b7d0dfcd7 Mon Sep 17 00:00:00 2001 From: Hernan Alvarado Date: Wed, 26 Aug 2026 19:09:57 -0500 Subject: [PATCH 2/3] security(core): harden credential sign-in with client ID validation --- packages/core/CHANGELOG.md | 2 ++ packages/core/src/@types/config.ts | 1 + packages/core/src/api/signInCredentials.ts | 3 +- packages/core/src/cookie.ts | 13 ++++++++ packages/core/src/router/context.ts | 2 +- packages/core/src/router/rate-limiter.ts | 20 ++++++++---- packages/core/src/shared/errors.ts | 18 +++++++++++ packages/core/src/shared/logger.ts | 24 ++++++++++++++ packages/core/src/shared/utils.ts | 32 +++++++++++++++++-- packages/core/src/shared/utils/api.ts | 14 ++++++-- .../signIn/signInCredentials/stateful.test.ts | 4 ++- .../signInCredentials/stateless.test.ts | 4 ++- .../core/test/api/stateful/getSession.test.ts | 1 - .../api/stateful/signInCredentials.test.ts | 4 ++- .../api/stateless/signInCredentials.test.ts | 4 ++- packages/core/test/presets.ts | 2 +- packages/core/test/rate-limiter.test.ts | 14 ++++---- 17 files changed, 136 insertions(+), 26 deletions(-) diff --git a/packages/core/CHANGELOG.md b/packages/core/CHANGELOG.md index 0c37e187..db9e1c24 100644 --- a/packages/core/CHANGELOG.md +++ b/packages/core/CHANGELOG.md @@ -10,6 +10,8 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), ### Security +- Hardened credential-based sign-in through the `POST /signIn/credentials` endpoint and `api.signInCredentials()` API. The operation now requires an additional `__Host-client_id_token` cookie to help ensure requests originate from trusted clients. [#269](https://github.com/aura-stack-ts/auth/pull/269) + - Hardened OAuth token handling in the `/providers/:provider/tokens` endpoint and `api.getProviderTokens()` API. The token storage cookie now uses the `__Host-` prefix, and responses include the `Cross-Origin-Resource-Policy: same-origin` and `Cross-Origin-Opener-Policy: same-origin` headers to strengthen cross-origin isolation and reduce the risk of cross-origin attacks. [#268](https://github.com/aura-stack-ts/auth/pull/268) --- diff --git a/packages/core/src/@types/config.ts b/packages/core/src/@types/config.ts index 28a55905..a4e28422 100644 --- a/packages/core/src/@types/config.ts +++ b/packages/core/src/@types/config.ts @@ -319,6 +319,7 @@ export type CookieName = | "redirectURI" | "nonce" | "accessToken" + | "clientIdToken" /** Resolved cookie names and serialization attributes for each logical auth cookie. */ export type CookieStoreConfig = Record diff --git a/packages/core/src/api/signInCredentials.ts b/packages/core/src/api/signInCredentials.ts index 04a8b837..7a1a9aaa 100644 --- a/packages/core/src/api/signInCredentials.ts +++ b/packages/core/src/api/signInCredentials.ts @@ -29,8 +29,9 @@ export const signInCredentials = async ({ toStandardizedHeaders(headerInit ?? requestInit?.headers ?? {}) ) .buildRequest(requestInit, "/signIn/credentials") - .verifyCSRFToken(skipCSRFCheck && !!doubleSubmitToken) + .verifyClientIdToken() .verifyRateLimit("signInCredentials") + .verifyCSRFToken(skipCSRFCheck && !!doubleSubmitToken) .execute() if (rateLimit) { diff --git a/packages/core/src/cookie.ts b/packages/core/src/cookie.ts index e27bc8fa..921524c9 100644 --- a/packages/core/src/cookie.ts +++ b/packages/core/src/cookie.ts @@ -289,5 +289,18 @@ export const createCookieStore = ( logger ), }, + clientIdToken: { + name: `${hostPrefix}${prefix}.${overrides?.clientIdToken?.name ?? "client_id_token"}`, + attributes: defineSecureCookieOptions( + useSecure, + { + ...overrides?.clientIdToken?.attributes, + ...defaultHostCookieConfig, + sameSite: "strict", + }, + overrides?.clientIdToken?.attributes?.strategy ?? "host", + logger + ), + }, } } diff --git a/packages/core/src/router/context.ts b/packages/core/src/router/context.ts index 7fc4ce92..f8686b2b 100644 --- a/packages/core/src/router/context.ts +++ b/packages/core/src/router/context.ts @@ -63,7 +63,7 @@ export const createContext = ctx.sessionStrategy = createSessionStrategy({ diff --git a/packages/core/src/router/rate-limiter.ts b/packages/core/src/router/rate-limiter.ts index 6630c503..fd6d1816 100644 --- a/packages/core/src/router/rate-limiter.ts +++ b/packages/core/src/router/rate-limiter.ts @@ -2,17 +2,23 @@ import { createRateLimiter, type RateLimiterRule } from "@aura-stack/rate-limite import type { RateLimiterConfig } from "@/@types/config.ts" import type { RouterGlobalContext } from "@/@types/internal.ts" -export const createRateLimiterInstance = (config?: RateLimiterConfig) => { +export const createRateLimiterInstance = (config?: RateLimiterConfig, useProxyHeaders: boolean = false) => { const getLimitKey = (request: Request, action: string): string => { - const ip = - request.headers.get("cf-connecting-ip") ?? - request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? - request.headers.get("x-real-ip") ?? - "anon" + const ip = useProxyHeaders + ? (request.headers.get("cf-connecting-ip") ?? + request.headers.get("x-forwarded-for")?.split(",")[0]?.trim() ?? + request.headers.get("x-real-ip") ?? + request.headers.get("true-client-ip") ?? + request.headers.get("x-client-ip") ?? + request.headers.get("x-cluster-client-ip") ?? + request.headers.get("x-forwarded") ?? + request.headers.get("forwarded-for") ?? + "anon") + : (request.headers.get("remote-addr") ?? "anon") return `rl:${action}:${ip}` } - return createRateLimiter({ + return createRateLimiter({ rules: { signIn: { algorithm: "sliding-window", diff --git a/packages/core/src/shared/errors.ts b/packages/core/src/shared/errors.ts index a0ed6e69..832d7844 100644 --- a/packages/core/src/shared/errors.ts +++ b/packages/core/src/shared/errors.ts @@ -139,6 +139,8 @@ export const AuraErrorCode = { */ INVALID_SLIDING_THRESHOLD_CONFIG_VALUE: "INVALID_SLIDING_THRESHOLD_CONFIG_VALUE", INVALID_CSRF_TOKEN: "INVALID_CSRF_TOKEN", + INVALID_CLIENT_ID_TOKEN: "INVALID_CLIENT_ID_TOKEN", + INVALID_BUILD_REQUEST: "INVALID_BUILD_REQUEST", } as const export type AuraErrorCode = (typeof AuraErrorCode)[keyof typeof AuraErrorCode] @@ -958,6 +960,22 @@ export const ERROR_CATALOG: Record = { "CSRF security verification failed. The provided anti-CSRF token does not match the token embedded in the secure session cookie context or failed cryptographic validation.", userMessage: "Security verification failed. Invalid or missing CSRF token.", }, + INVALID_CLIENT_ID_TOKEN: { + type: "VALIDATION", + statusCode: 400, + name: "AuthValidationError", + message: + "The requested authentication operation could not be performed. The client identification parameter is missing from the request context or the provided client ID token failed security validation checks.", + userMessage: "Invalid request. The client identifier or token provided is invalid.", + }, + INVALID_BUILD_REQUEST: { + type: "VALIDATION", + statusCode: 400, + name: "RequestBuildError", + message: + "The internal request object could not be constructed during the pipeline validation sequence. Initialized request values, rate-limiter contexts, or client parameters are malformed or invalid.", + userMessage: "Invalid request state. The request could not be processed during initial validation checks.", + }, } export interface AuraErrorOptions extends ErrorOptions { diff --git a/packages/core/src/shared/logger.ts b/packages/core/src/shared/logger.ts index e2afd3c0..90e399e2 100644 --- a/packages/core/src/shared/logger.ts +++ b/packages/core/src/shared/logger.ts @@ -904,6 +904,30 @@ export const LOG_MESSAGES = { msgId: "UPDATE_SESSION_INVALID", message: "Session update failed due to invalid session state", }, + CLIENT_ID_TOKEN_REQUESTED: { + facility: 4, + severity: "debug", + msgId: "CLIENT_ID_TOKEN_REQUESTED", + message: "Client requested a new client ID token", + }, + CLIENT_ID_TOKEN_VERIFIED: { + facility: 4, + severity: "info", + msgId: "CLIENT_ID_TOKEN_VERIFIED", + message: "Client ID token verification succeeded", + }, + CLIENT_ID_TOKEN_VERIFIED_SUCCESS: { + facility: 4, + severity: "info", + msgId: "CLIENT_ID_TOKEN_VERIFIED_SUCCESS", + message: "Client ID token verified successfully", + }, + INVALID_CLIENT_ID_TOKEN: { + facility: 4, + severity: "error", + msgId: "INVALID_CLIENT_ID_TOKEN", + message: "Client ID token validation failed or token is invalid", + }, } as const export const createLogEntry = ( diff --git a/packages/core/src/shared/utils.ts b/packages/core/src/shared/utils.ts index 1b624d14..a8f32ab5 100644 --- a/packages/core/src/shared/utils.ts +++ b/packages/core/src/shared/utils.ts @@ -1,9 +1,9 @@ import { getEnv } from "@/shared/env.ts" import { getCookie } from "@/cookie.ts" -import { createHash, verifyCSRF } from "@/shared/crypto.ts" import { encoder } from "@aura-stack/jose/crypto" import { AuraAuthError } from "@/shared/errors.ts" import { isRelativeURL, isString, isValidURL } from "@/shared/assert.ts" +import { createHash, createSecretValue, verifyCSRF } from "@/shared/crypto.ts" import type { DeviceType } from "@/@types/entities.ts" import type { JoseInstance, OAuthTokenPayload } from "@/@types/index.ts" import type { @@ -12,6 +12,7 @@ import type { SchemaRegistryContext, OAuthAccessTokenResponseType, JWTManager, + RouterGlobalContext, } from "@/@types/internal.ts" export const AURA_AUTH_VERSION = "0.9.0" @@ -176,7 +177,6 @@ export const verifyCSRFToken = async ({ try { csrfToken = csrfToken || getCookie(headers, cookies.csrfToken.name) } catch (cause) { - console.log("CSRF_TOKEN_MISSING in cookie retrieval", cause) logger?.log("CSRF_TOKEN_MISSING") throw new AuraAuthError({ code: "CSRF_TOKEN_MISSING", cause }) } @@ -329,3 +329,31 @@ export const getDeviceInfo = (request: Request) => { deviceType: getDeviceType(userAgent, secChUaMobile), } } + +export const createClientIdToken = async (jose: RouterGlobalContext["jose"], clientIdToken?: string) => { + try { + if (clientIdToken) { + await jose.verifyJWS(clientIdToken) + return clientIdToken + } + const token = createSecretValue(32) + return jose.signJWS({ token }) + } catch { + const token = createSecretValue(32) + return jose.signJWS({ token }) + } +} + +export const verifyClientIdToken = async (request: Request, ctx: RouterGlobalContext): Promise => { + try { + ctx.logger?.log("CLIENT_ID_TOKEN_REQUESTED") + const clientIdToken = getCookie(request, ctx.cookies.clientIdToken.name) + ctx.logger?.log("CLIENT_ID_TOKEN_VERIFIED") + const jws = await ctx.jose.verifyJWS(clientIdToken) + ctx.logger?.log("CLIENT_ID_TOKEN_VERIFIED_SUCCESS") + return jws!.token as string + } catch { + ctx.logger?.log("INVALID_CLIENT_ID_TOKEN") + throw new AuraAuthError({ code: "INVALID_CLIENT_ID_TOKEN" }) + } +} diff --git a/packages/core/src/shared/utils/api.ts b/packages/core/src/shared/utils/api.ts index c713d05c..ed9fa464 100644 --- a/packages/core/src/shared/utils/api.ts +++ b/packages/core/src/shared/utils/api.ts @@ -5,7 +5,7 @@ import { verifyRateLimit } from "@/router/rate-limiter.ts" import { createCookieManager } from "@/session/cookie-manager.ts" import { AuraAuthError, isAuraAuthError } from "@/shared/errors.ts" import { isHeadersInit, isStatelessStrategy } from "@/shared/assert.ts" -import { getErrorName, verifyCSRFToken, verifySessionToken } from "@/shared/utils.ts" +import { getErrorName, verifyClientIdToken as assertClientIdToken, verifyCSRFToken, verifySessionToken } from "@/shared/utils.ts" import { getBaseURL, getOriginURL, createRedirectTo } from "@/shared/utils/authorization.ts" import type { BuiltInOAuthProvider, @@ -29,6 +29,7 @@ export const createValidation = (ctx: RouterGlobalContext, headersInit?: Headers const output: { provider?: RuntimeOAuthProvider request?: Request + clientId?: string rateLimit?: any headers: Headers } = { headers } @@ -102,7 +103,7 @@ export const createValidation = (ctx: RouterGlobalContext, headersInit?: Headers verifyRateLimit: (action: keyof RateLimiterConfig) => { steps.push(async () => { if (!output.request) { - throw new Error("buildRequest must be called before verifyRateLimit") + throw new AuraAuthError({ code: "INVALID_BUILD_REQUEST" }) } const rateLimit = await verifyRateLimit(ctx, output.request, action) if (rateLimit) { @@ -111,6 +112,15 @@ export const createValidation = (ctx: RouterGlobalContext, headersInit?: Headers }) return builder }, + verifyClientIdToken: () => { + steps.push(async () => { + if (!output.request) { + throw new AuraAuthError({ code: "INVALID_BUILD_REQUEST" }) + } + output.clientId = await assertClientIdToken(output.request, ctx) + }) + return builder + }, execute: async () => { for (const step of steps) { await step() diff --git a/packages/core/test/actions/signIn/signInCredentials/stateful.test.ts b/packages/core/test/actions/signIn/signInCredentials/stateful.test.ts index 7d8b7d0b..cb5edff8 100644 --- a/packages/core/test/actions/signIn/signInCredentials/stateful.test.ts +++ b/packages/core/test/actions/signIn/signInCredentials/stateful.test.ts @@ -2,14 +2,16 @@ import { describe, test, expect, vi } from "vitest" import { authInstance, deviceEntity, jose, sessionEntityWithUser, userEntity } from "@test/presets.ts" import { createCSRF } from "@/shared/crypto.ts" import { createSchemaRegistry } from "@/validator/registry.ts" +import { createClientIdToken } from "@/shared/utils.ts" describe("signInCredentials action", async () => { const csrfToken = await createCSRF(jose) + const clientId = await createClientIdToken(jose) const headers = { "Content-Type": "application/json", "X-CSRF-Token": csrfToken, - Cookie: `aura-auth.csrf_token=${csrfToken}`, + Cookie: `aura-auth.csrf_token=${csrfToken}; aura-auth.client_id_token=${clientId}`, } test("success signIn flow", async () => { diff --git a/packages/core/test/actions/signIn/signInCredentials/stateless.test.ts b/packages/core/test/actions/signIn/signInCredentials/stateless.test.ts index 69d88e2c..ddb41b13 100644 --- a/packages/core/test/actions/signIn/signInCredentials/stateless.test.ts +++ b/packages/core/test/actions/signIn/signInCredentials/stateless.test.ts @@ -3,14 +3,16 @@ import { jose, POST } from "@test/presets.ts" import { getSetCookie } from "@/cookie.ts" import { createAuth } from "@/createAuth.ts" import { createCSRF } from "@/shared/crypto.ts" +import { createClientIdToken } from "@/shared/utils.ts" describe("signInCredentials action", async () => { const csrfToken = await createCSRF(jose) + const clientIdToken = await createClientIdToken(jose) const headers = { "Content-Type": "application/json", "X-CSRF-Token": csrfToken, - Cookie: `aura-auth.csrf_token=${csrfToken}`, + Cookie: `aura-auth.csrf_token=${csrfToken}; aura-auth.client_id_token=${clientIdToken}`, } test("success signIn flow", async () => { diff --git a/packages/core/test/api/stateful/getSession.test.ts b/packages/core/test/api/stateful/getSession.test.ts index 07ce52cd..15d90abe 100644 --- a/packages/core/test/api/stateful/getSession.test.ts +++ b/packages/core/test/api/stateful/getSession.test.ts @@ -101,7 +101,6 @@ describe("getSession", () => { expect(sessionByTokenMock).toHaveBeenCalledWith(tokenHash) expect(revokeSessionMock).toHaveBeenCalledWith("session-123", "user_logout") expect(spy).not.toHaveBeenCalled() - console.log("value: ") expect(() => getSetCookie(output.headers, "aura-auth.csrf_token")).toThrow() expect(getSetCookie(output.headers, "aura-auth.session_token")).toBe("") }) diff --git a/packages/core/test/api/stateful/signInCredentials.test.ts b/packages/core/test/api/stateful/signInCredentials.test.ts index 815e506e..e7453bf1 100644 --- a/packages/core/test/api/stateful/signInCredentials.test.ts +++ b/packages/core/test/api/stateful/signInCredentials.test.ts @@ -2,12 +2,14 @@ import { describe, test, expect, vi } from "vitest" import { createCSRF } from "@/shared/crypto.ts" import { authInstance, deviceEntity, jose, sessionEntityWithUser, userEntity } from "@test/presets.ts" import { createSchemaRegistry } from "@/validator/registry.ts" +import { createClientIdToken } from "@/shared/utils.ts" describe("signInCredentials API", async () => { const csrfToken = await createCSRF(jose) + const clientId = await createClientIdToken(jose) const headers = { - Cookie: `aura-auth.csrf_token=${csrfToken}`, + Cookie: `aura-auth.csrf_token=${csrfToken}; aura-auth.client_id_token=${clientId}`, } test("success signIn flow", async () => { diff --git a/packages/core/test/api/stateless/signInCredentials.test.ts b/packages/core/test/api/stateless/signInCredentials.test.ts index 1dd2682d..d0b206f5 100644 --- a/packages/core/test/api/stateless/signInCredentials.test.ts +++ b/packages/core/test/api/stateless/signInCredentials.test.ts @@ -3,12 +3,14 @@ import { createAuth } from "@/createAuth.ts" import { getSetCookie } from "@/cookie.ts" import { api, jose } from "@test/presets.ts" import { createCSRF } from "@/shared/crypto.ts" +import { createClientIdToken } from "@/shared/utils.ts" describe("signInCredentials API", async () => { const csrfToken = await createCSRF(jose) + const clientId = await createClientIdToken(jose) const headers = { - Cookie: `aura-auth.csrf_token=${csrfToken}`, + Cookie: `aura-auth.csrf_token=${csrfToken}; aura-auth.client_id_token=${clientId}`, } test("success signIn flow", async () => { diff --git a/packages/core/test/presets.ts b/packages/core/test/presets.ts index e3f7a07a..2e509cb6 100644 --- a/packages/core/test/presets.ts +++ b/packages/core/test/presets.ts @@ -173,7 +173,7 @@ export const oauthTransactionEntity: OAuthTransactionEntity = { const auth = createAuth({ oauth: [oauthCustomService, oauthCustomServiceProfile, openIDCustomProvider], - logger: getEnv("CI") === "true" ? false : true, + //logger: getEnv("CI") === "true" ? false : true, credentials: { authorize: async ({ credentials }) => { const { username } = credentials diff --git a/packages/core/test/rate-limiter.test.ts b/packages/core/test/rate-limiter.test.ts index 78c9199a..ef79b1f3 100644 --- a/packages/core/test/rate-limiter.test.ts +++ b/packages/core/test/rate-limiter.test.ts @@ -1,21 +1,21 @@ import { describe, expect, test } from "vitest" import { jose, PATCH, POST, sessionPayload } from "./presets.ts" import { createCSRF } from "@/shared/crypto.ts" +import { createClientIdToken, equals } from "@/shared/utils.ts" describe("Rate Limiter", async () => { const csrfToken = await createCSRF(jose) + const clientIdToken = await createClientIdToken(jose) const createRequest = async (makeRequest: () => Request, totalRequests: number, allowedLimit: number) => { const expectedRejections = totalRequests - allowedLimit - const requests = Array.from({ length: totalRequests }).map(() => makeRequest()) + const requests = Array.from({ length: totalRequests }, makeRequest) const responses = await Promise.all(requests.map((req) => (req.method === "PATCH" ? PATCH(req) : POST(req)))) - const successfulResponses = responses.filter((res) => res.status === 200) - const rejectedResponses = responses.filter((res) => res.status === 429) - - expect(successfulResponses.length).toBe(allowedLimit) + const successfulResponses = responses.filter((res) => equals(res.status, 200)) + const rejectedResponses = responses.filter((res) => equals(res.status, 429)) expect(rejectedResponses.length).toBe(expectedRejections) - + expect(successfulResponses.length).toBe(allowedLimit) if (rejectedResponses.length > 0) { const targetReject = rejectedResponses[0] expect(targetReject.headers.get("Retry-After")).toBeDefined() @@ -33,7 +33,7 @@ describe("Rate Limiter", async () => { headers: { "x-forwarded-for": "192.168.1.50", "X-CSRF-Token": csrfToken, - Cookie: `aura-auth.csrf_token=${csrfToken}`, + Cookie: `aura-auth.csrf_token=${csrfToken}; aura-auth.client_id_token=${clientIdToken}`, }, }) await createRequest(makeRequest, 10, 8) From 1c5917284f21fe84b48661cd7dd2910d5f155bad Mon Sep 17 00:00:00 2001 From: Hernan Alvarado Date: Wed, 26 Aug 2026 19:30:43 -0500 Subject: [PATCH 3/3] test: update tests --- packages/core/src/shared/crypto.ts | 36 +++++++++++++++++++ packages/core/src/shared/utils.ts | 31 +--------------- packages/core/src/shared/utils/api.ts | 4 +-- .../signIn/signInCredentials/stateful.test.ts | 3 +- .../signInCredentials/stateless.test.ts | 3 +- .../api/stateful/signInCredentials.test.ts | 3 +- .../api/stateless/signInCredentials.test.ts | 3 +- packages/core/test/rate-limiter.test.ts | 4 +-- packages/elysia/test/stateful/signIn.test.ts | 4 +-- .../test/stateful/signInCredentials.test.ts | 11 +++--- packages/elysia/test/stateless/index.test.ts | 8 +++-- packages/express/test/index.test.ts | 8 +++-- packages/hono/test/index.test.ts | 8 +++-- .../next/test/pages-router/handler.test.ts | 5 +-- packages/oak/test/index.test.ts | 15 +++++--- packages/react/vitest.config.ts | 4 +-- 16 files changed, 85 insertions(+), 65 deletions(-) diff --git a/packages/core/src/shared/crypto.ts b/packages/core/src/shared/crypto.ts index cf030d0b..aa0a6b02 100644 --- a/packages/core/src/shared/crypto.ts +++ b/packages/core/src/shared/crypto.ts @@ -5,6 +5,7 @@ import { base64url, encoder, getRandomBytes, getSubtleCrypto } from "@/jose.ts" import { exportJWK, generateKeyPair, importPKCS8, importSPKI, type GenerateKeyPairOptions } from "@aura-stack/jose/jose" import type { JoseInstance, User } from "@/@types/index.ts" import type { AsymmetricKeyPairFromEnv, RouterGlobalContext } from "@/@types/internal.ts" +import { getCookie } from "@/cookie.ts" export { generateKeyPair as createKeyPair } from "@aura-stack/jose/jose" @@ -68,6 +69,13 @@ export const createCSRF = async (jose: RouterGlobalContext["jose"], csrfCookie?: } } +/** + * Creates a CSRF token to be used in OAuth flows to prevent cross-site request forgery attacks. + * + * @param csrfCookie - Optional existing CSRF cookie to verify and reuse + * @returns Signed CSRF token + */ + export const verifyCSRF = async ( jose: JoseInstance, cookie: string, @@ -187,3 +195,31 @@ export const exportJWKKeyPair = async (alg: string, options?: GenerateKeyPairOpt privateKey: jwkPrivateKey, } } + +export const createClientIdToken = async (jose: RouterGlobalContext["jose"], clientIdToken?: string) => { + try { + if (clientIdToken) { + await jose.verifyJWS(clientIdToken) + return clientIdToken + } + const token = createSecretValue(32) + return jose.signJWS({ token }) + } catch { + const token = createSecretValue(32) + return jose.signJWS({ token }) + } +} + +export const verifyClientIdToken = async (request: Request, ctx: RouterGlobalContext): Promise => { + try { + ctx.logger?.log("CLIENT_ID_TOKEN_REQUESTED") + const clientIdToken = getCookie(request, ctx.cookies.clientIdToken.name) + ctx.logger?.log("CLIENT_ID_TOKEN_VERIFIED") + const jws = await ctx.jose.verifyJWS(clientIdToken) + ctx.logger?.log("CLIENT_ID_TOKEN_VERIFIED_SUCCESS") + return jws!.token as string + } catch { + ctx.logger?.log("INVALID_CLIENT_ID_TOKEN") + throw new AuraAuthError({ code: "INVALID_CLIENT_ID_TOKEN" }) + } +} diff --git a/packages/core/src/shared/utils.ts b/packages/core/src/shared/utils.ts index a8f32ab5..5d06f6cf 100644 --- a/packages/core/src/shared/utils.ts +++ b/packages/core/src/shared/utils.ts @@ -3,7 +3,7 @@ import { getCookie } from "@/cookie.ts" import { encoder } from "@aura-stack/jose/crypto" import { AuraAuthError } from "@/shared/errors.ts" import { isRelativeURL, isString, isValidURL } from "@/shared/assert.ts" -import { createHash, createSecretValue, verifyCSRF } from "@/shared/crypto.ts" +import { createHash, verifyCSRF } from "@/shared/crypto.ts" import type { DeviceType } from "@/@types/entities.ts" import type { JoseInstance, OAuthTokenPayload } from "@/@types/index.ts" import type { @@ -12,7 +12,6 @@ import type { SchemaRegistryContext, OAuthAccessTokenResponseType, JWTManager, - RouterGlobalContext, } from "@/@types/internal.ts" export const AURA_AUTH_VERSION = "0.9.0" @@ -329,31 +328,3 @@ export const getDeviceInfo = (request: Request) => { deviceType: getDeviceType(userAgent, secChUaMobile), } } - -export const createClientIdToken = async (jose: RouterGlobalContext["jose"], clientIdToken?: string) => { - try { - if (clientIdToken) { - await jose.verifyJWS(clientIdToken) - return clientIdToken - } - const token = createSecretValue(32) - return jose.signJWS({ token }) - } catch { - const token = createSecretValue(32) - return jose.signJWS({ token }) - } -} - -export const verifyClientIdToken = async (request: Request, ctx: RouterGlobalContext): Promise => { - try { - ctx.logger?.log("CLIENT_ID_TOKEN_REQUESTED") - const clientIdToken = getCookie(request, ctx.cookies.clientIdToken.name) - ctx.logger?.log("CLIENT_ID_TOKEN_VERIFIED") - const jws = await ctx.jose.verifyJWS(clientIdToken) - ctx.logger?.log("CLIENT_ID_TOKEN_VERIFIED_SUCCESS") - return jws!.token as string - } catch { - ctx.logger?.log("INVALID_CLIENT_ID_TOKEN") - throw new AuraAuthError({ code: "INVALID_CLIENT_ID_TOKEN" }) - } -} diff --git a/packages/core/src/shared/utils/api.ts b/packages/core/src/shared/utils/api.ts index ed9fa464..9834a197 100644 --- a/packages/core/src/shared/utils/api.ts +++ b/packages/core/src/shared/utils/api.ts @@ -1,11 +1,11 @@ import { HeadersBuilder, type RequestHeaders } from "@aura-stack/router" import { getOptionalCookie } from "@/cookie.ts" -import { assertCSRFTokenCookie, createCSRF, createHash } from "@/shared/crypto.ts" +import { assertCSRFTokenCookie, createCSRF, createHash, verifyClientIdToken as assertClientIdToken } from "@/shared/crypto.ts" import { verifyRateLimit } from "@/router/rate-limiter.ts" import { createCookieManager } from "@/session/cookie-manager.ts" import { AuraAuthError, isAuraAuthError } from "@/shared/errors.ts" import { isHeadersInit, isStatelessStrategy } from "@/shared/assert.ts" -import { getErrorName, verifyClientIdToken as assertClientIdToken, verifyCSRFToken, verifySessionToken } from "@/shared/utils.ts" +import { getErrorName, verifyCSRFToken, verifySessionToken } from "@/shared/utils.ts" import { getBaseURL, getOriginURL, createRedirectTo } from "@/shared/utils/authorization.ts" import type { BuiltInOAuthProvider, diff --git a/packages/core/test/actions/signIn/signInCredentials/stateful.test.ts b/packages/core/test/actions/signIn/signInCredentials/stateful.test.ts index cb5edff8..e10a9f71 100644 --- a/packages/core/test/actions/signIn/signInCredentials/stateful.test.ts +++ b/packages/core/test/actions/signIn/signInCredentials/stateful.test.ts @@ -1,8 +1,7 @@ import { describe, test, expect, vi } from "vitest" import { authInstance, deviceEntity, jose, sessionEntityWithUser, userEntity } from "@test/presets.ts" -import { createCSRF } from "@/shared/crypto.ts" +import { createCSRF, createClientIdToken } from "@/shared/crypto.ts" import { createSchemaRegistry } from "@/validator/registry.ts" -import { createClientIdToken } from "@/shared/utils.ts" describe("signInCredentials action", async () => { const csrfToken = await createCSRF(jose) diff --git a/packages/core/test/actions/signIn/signInCredentials/stateless.test.ts b/packages/core/test/actions/signIn/signInCredentials/stateless.test.ts index ddb41b13..83ad1508 100644 --- a/packages/core/test/actions/signIn/signInCredentials/stateless.test.ts +++ b/packages/core/test/actions/signIn/signInCredentials/stateless.test.ts @@ -2,8 +2,7 @@ import { describe, test, expect } from "vitest" import { jose, POST } from "@test/presets.ts" import { getSetCookie } from "@/cookie.ts" import { createAuth } from "@/createAuth.ts" -import { createCSRF } from "@/shared/crypto.ts" -import { createClientIdToken } from "@/shared/utils.ts" +import { createCSRF, createClientIdToken } from "@/shared/crypto.ts" describe("signInCredentials action", async () => { const csrfToken = await createCSRF(jose) diff --git a/packages/core/test/api/stateful/signInCredentials.test.ts b/packages/core/test/api/stateful/signInCredentials.test.ts index e7453bf1..46f28d4d 100644 --- a/packages/core/test/api/stateful/signInCredentials.test.ts +++ b/packages/core/test/api/stateful/signInCredentials.test.ts @@ -1,8 +1,7 @@ import { describe, test, expect, vi } from "vitest" -import { createCSRF } from "@/shared/crypto.ts" +import { createCSRF, createClientIdToken } from "@/shared/crypto.ts" import { authInstance, deviceEntity, jose, sessionEntityWithUser, userEntity } from "@test/presets.ts" import { createSchemaRegistry } from "@/validator/registry.ts" -import { createClientIdToken } from "@/shared/utils.ts" describe("signInCredentials API", async () => { const csrfToken = await createCSRF(jose) diff --git a/packages/core/test/api/stateless/signInCredentials.test.ts b/packages/core/test/api/stateless/signInCredentials.test.ts index d0b206f5..946578bb 100644 --- a/packages/core/test/api/stateless/signInCredentials.test.ts +++ b/packages/core/test/api/stateless/signInCredentials.test.ts @@ -2,8 +2,7 @@ import { describe, test, expect, vi } from "vitest" import { createAuth } from "@/createAuth.ts" import { getSetCookie } from "@/cookie.ts" import { api, jose } from "@test/presets.ts" -import { createCSRF } from "@/shared/crypto.ts" -import { createClientIdToken } from "@/shared/utils.ts" +import { createCSRF, createClientIdToken } from "@/shared/crypto.ts" describe("signInCredentials API", async () => { const csrfToken = await createCSRF(jose) diff --git a/packages/core/test/rate-limiter.test.ts b/packages/core/test/rate-limiter.test.ts index ef79b1f3..772082b5 100644 --- a/packages/core/test/rate-limiter.test.ts +++ b/packages/core/test/rate-limiter.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from "vitest" import { jose, PATCH, POST, sessionPayload } from "./presets.ts" -import { createCSRF } from "@/shared/crypto.ts" -import { createClientIdToken, equals } from "@/shared/utils.ts" +import { createCSRF, createClientIdToken } from "@/shared/crypto.ts" +import { equals } from "@/shared/utils.ts" describe("Rate Limiter", async () => { const csrfToken = await createCSRF(jose) diff --git a/packages/elysia/test/stateful/signIn.test.ts b/packages/elysia/test/stateful/signIn.test.ts index 104cf37a..6097e3b8 100644 --- a/packages/elysia/test/stateful/signIn.test.ts +++ b/packages/elysia/test/stateful/signIn.test.ts @@ -94,7 +94,7 @@ describe("signIn (Stateful)", () => { expect(mockFetch).toHaveBeenNthCalledWith(2, "https://api.github.com/user", { method: "GET", headers: { - "User-Agent": `Aura Auth/0.8.1`, + "User-Agent": `Aura Auth/0.9.0`, Accept: "application/json", Authorization: "Bearer access_token_123", }, @@ -228,7 +228,7 @@ describe("signIn (Stateful)", () => { expect(mockFetch).toHaveBeenNthCalledWith(2, "https://api.github.com/user", { method: "GET", headers: { - "User-Agent": `Aura Auth/0.8.1`, + "User-Agent": `Aura Auth/0.9.0`, Accept: "application/json", Authorization: "Bearer access_token_123", }, diff --git a/packages/elysia/test/stateful/signInCredentials.test.ts b/packages/elysia/test/stateful/signInCredentials.test.ts index b6e31971..0d905728 100644 --- a/packages/elysia/test/stateful/signInCredentials.test.ts +++ b/packages/elysia/test/stateful/signInCredentials.test.ts @@ -1,18 +1,19 @@ import { describe, test, expect } from "vitest" -import { createCSRF } from "@aura-stack/auth/crypto" +import { createCSRF, createClientIdToken } from "@aura-stack/auth/crypto" import { adapter, app, auth } from "@test/stateful/app" import { parseSetCookie } from "@aura-stack/auth/cookies" describe("signInCredentials (Stateful)", () => { test("returns 401 when invalid credentials are provided", async () => { const csrfToken = await createCSRF(auth.jose) + const clientIdToken = await createClientIdToken(auth.jose) const response = await app.handle( new Request("http://localhost:3000/api/auth/signIn/credentials", { method: "POST", headers: { "X-CSRF-Token": csrfToken, - Cookie: `aura-auth.csrf_token=${csrfToken}`, + Cookie: `aura-auth.csrf_token=${csrfToken}; aura-auth.client_id_token=${clientIdToken}`, }, body: JSON.stringify({ username: "invalid", password: "invalid" }), }) @@ -28,6 +29,7 @@ describe("signInCredentials (Stateful)", () => { test("returns 200 and a session cookie when valid credentials are provided", async () => { const csrfToken = await createCSRF(auth.jose) + const clientIdToken = await createClientIdToken(auth.jose) await adapter.createUser({ id: "credentials:valid", @@ -42,7 +44,7 @@ describe("signInCredentials (Stateful)", () => { headers: { "Content-Type": "application/json", "X-CSRF-Token": csrfToken, - Cookie: `aura-auth.csrf_token=${csrfToken}`, + Cookie: `aura-auth.csrf_token=${csrfToken}; aura-auth.client_id_token=${clientIdToken}`, }, body: JSON.stringify({ username: "valid", password: "valid" }), }) @@ -78,6 +80,7 @@ describe("signInCredentials (Stateful)", () => { test("sign in credentials fails with CSRF token mismatch", async () => { const csrfToken = await createCSRF(auth.jose) + const clientIdToken = await createClientIdToken(auth.jose) const response = await app.handle( new Request("http://localhost:3000/api/auth/signIn/credentials", { @@ -85,7 +88,7 @@ describe("signInCredentials (Stateful)", () => { headers: { "Content-Type": "application/json", "X-CSRF-Token": csrfToken, - Cookie: `aura-auth.csrf_token=different-token`, + Cookie: `aura-auth.csrf_token=different-token; aura-auth.client_id_token=${clientIdToken}`, }, body: JSON.stringify({ username: "valid", password: "valid" }), }) diff --git a/packages/elysia/test/stateless/index.test.ts b/packages/elysia/test/stateless/index.test.ts index 876239a8..a8bf63e5 100644 --- a/packages/elysia/test/stateless/index.test.ts +++ b/packages/elysia/test/stateless/index.test.ts @@ -1,6 +1,6 @@ import { describe, test, expect } from "vitest" import { app, auth } from "@test/stateless/app.ts" -import { createCSRF } from "@aura-stack/auth/crypto" +import { createClientIdToken, createCSRF } from "@aura-stack/auth/crypto" describe("GET /api/auth/signIn/github", () => { test("redirects to GitHub's OAuth page", async () => { @@ -98,13 +98,14 @@ describe("GET /api/protected", () => { describe("POST /api/auth/signIn/credentials", () => { test("returns 401 when invalid credentials are provided", async () => { const csrfToken = await createCSRF(auth.jose) + const clientIdToken = await createClientIdToken(auth.jose) const res = await app.handle( new Request("http://localhost/api/auth/signIn/credentials", { method: "POST", headers: { "X-CSRF-Token": csrfToken, - Cookie: `aura-auth.csrf_token=${csrfToken}`, + Cookie: `aura-auth.csrf_token=${csrfToken}; aura-auth.client_id_token=${clientIdToken}`, }, body: JSON.stringify({ username: "invalid", password: "invalid" }), }) @@ -119,6 +120,7 @@ describe("POST /api/auth/signIn/credentials", () => { test("returns 200 and a session cookie when valid credentials are provided", async () => { const csrfToken = await createCSRF(auth.jose) + const clientIdToken = await createClientIdToken(auth.jose) const res = await app.handle( new Request("http://localhost/api/auth/signIn/credentials", { @@ -126,7 +128,7 @@ describe("POST /api/auth/signIn/credentials", () => { headers: { "Content-Type": "application/json", "X-CSRF-Token": csrfToken, - Cookie: `aura-auth.csrf_token=${csrfToken}`, + Cookie: `aura-auth.csrf_token=${csrfToken}; aura-auth.client_id_token=${clientIdToken}`, }, body: JSON.stringify({ username: "valid", password: "valid" }), }) diff --git a/packages/express/test/index.test.ts b/packages/express/test/index.test.ts index b89a373d..29305303 100644 --- a/packages/express/test/index.test.ts +++ b/packages/express/test/index.test.ts @@ -1,7 +1,7 @@ import { describe, test, expect } from "vitest" import supertest from "supertest" import { app, auth } from "./presets.ts" -import { createCSRF } from "@aura-stack/auth/crypto" +import { createClientIdToken, createCSRF } from "@aura-stack/auth/crypto" describe("GET /api/auth/signIn/github", () => { test("redirects to GitHub's OAuth page", async () => { @@ -88,12 +88,13 @@ describe("GET /api/protected", () => { describe("POST /api/auth/signIn/credentials", () => { test("returns 401 when invalid credentials are provided", async () => { const csrfToken = await createCSRF(auth.jose) + const clientIdToken = await createClientIdToken(auth.jose) const response = await supertest(app) .post("/api/auth/signIn/credentials") .send({ username: "invalid", password: "invalid" }) .set("X-CSRF-Token", csrfToken) - .set("Cookie", `aura-auth.csrf_token=${csrfToken}`) + .set("Cookie", `aura-auth.csrf_token=${csrfToken}; aura-auth.client_id_token=${clientIdToken}`) expect(response.status).toBe(401) expect(response.body).toMatchObject({ success: false, @@ -103,12 +104,13 @@ describe("POST /api/auth/signIn/credentials", () => { test("returns 200 and a session cookie when valid credentials are provided", async () => { const csrfToken = await createCSRF(auth.jose) + const clientId = await createClientIdToken(auth.jose) const response = await supertest(app) .post("/api/auth/signIn/credentials") .send({ username: "valid", password: "valid" }) .set("X-CSRF-Token", csrfToken) - .set("Cookie", `aura-auth.csrf_token=${csrfToken}`) + .set("Cookie", `aura-auth.csrf_token=${csrfToken}; aura-auth.client_id_token=${clientId}`) expect(response.status).toBe(200) expect(response.body).toMatchObject({ success: true, diff --git a/packages/hono/test/index.test.ts b/packages/hono/test/index.test.ts index 47c2f41d..879a587e 100644 --- a/packages/hono/test/index.test.ts +++ b/packages/hono/test/index.test.ts @@ -1,6 +1,6 @@ import { describe, test, expect } from "vitest" import { app, auth } from "./presets.ts" -import { createCSRF } from "@aura-stack/auth/crypto" +import { createClientIdToken, createCSRF } from "@aura-stack/auth/crypto" describe("GET /api/auth/signIn/github", () => { test("redirects to GitHub's OAuth page", async () => { @@ -94,13 +94,14 @@ describe("GET /api/protected", () => { describe("POST /api/auth/signIn/credentials", () => { test("returns 401 when invalid credentials are provided", async () => { const csrfToken = await createCSRF(auth.jose) + const clientIdToken = await createClientIdToken(auth.jose) const res = await app.request("/api/auth/signIn/credentials", { method: "POST", headers: { "Content-Type": "application/json", "X-CSRF-Token": csrfToken, - Cookie: `aura-auth.csrf_token=${csrfToken}`, + Cookie: `aura-auth.csrf_token=${csrfToken}; aura-auth.client_id_token=${clientIdToken}`, }, body: JSON.stringify({ username: "invalid", password: "invalid" }), }) @@ -114,13 +115,14 @@ describe("POST /api/auth/signIn/credentials", () => { test("returns 200 and a session cookie when valid credentials are provided", async () => { const csrfToken = await createCSRF(auth.jose) + const clientIdToken = await createClientIdToken(auth.jose) const res = await app.request("/api/auth/signIn/credentials", { method: "POST", headers: { "Content-Type": "application/json", "X-CSRF-Token": csrfToken, - Cookie: `aura-auth.csrf_token=${csrfToken}`, + Cookie: `aura-auth.csrf_token=${csrfToken}; aura-auth.client_id_token=${clientIdToken}`, }, body: JSON.stringify({ username: "valid", password: "valid" }), }) diff --git a/packages/next/test/pages-router/handler.test.ts b/packages/next/test/pages-router/handler.test.ts index 649a4535..c33e6eed 100644 --- a/packages/next/test/pages-router/handler.test.ts +++ b/packages/next/test/pages-router/handler.test.ts @@ -1,7 +1,7 @@ import { describe, test, expect } from "vitest" import { createMocks, createResponse, type RequestOptions, type ResponseOptions } from "node-mocks-http" import { auth } from "@test/pages-router/preset" -import { createCSRF } from "@aura-stack/react/crypto" +import { createClientIdToken, createCSRF } from "@aura-stack/react/crypto" import { setResponseHeaders } from "@/pages/handler" import type { NextApiRequest, NextApiResponse } from "next" @@ -83,6 +83,7 @@ describe("toHandler", () => { test("POST /auth/signIn/credentials", async () => { const csrfToken = await createCSRF(auth.jose) + const clientIdToken = await createClientIdToken(auth.jose) const { res } = await createHandler({ method: "POST", @@ -92,7 +93,7 @@ describe("toHandler", () => { "X-Forwarded-Proto": "http", "Content-Type": "application/json", "X-CSRF-Token": csrfToken, - Cookie: `aura-auth.csrf_token=${csrfToken}`, + Cookie: `aura-auth.csrf_token=${csrfToken}; aura-auth.client_id_token=${clientIdToken}`, }, body: { username: "john.doe", diff --git a/packages/oak/test/index.test.ts b/packages/oak/test/index.test.ts index 9544bb6a..14a5d1a3 100644 --- a/packages/oak/test/index.test.ts +++ b/packages/oak/test/index.test.ts @@ -1,6 +1,6 @@ import { equal, assertNotEquals, assertObjectMatch, assertExists } from "@std/assert" import { app, jose } from "./app.ts" -import { createCSRF } from "@aura-stack/auth/crypto" +import { createCSRF, createClientIdToken } from "@aura-stack/auth/crypto" import type { JWTPayload } from "@aura-stack/jose/jose" export const sessionPayload: JWTPayload = { @@ -14,6 +14,10 @@ const createSessionToken = async (payload: JWTPayload): Promise => { return await jose.encodeJWT(payload) } +const createClientId = async (): Promise => { + return await createClientIdToken(jose) +} + const createCSRFToken = async (): Promise => { return await createCSRF(jose) } @@ -51,12 +55,13 @@ Deno.test("signIn with invalid provider", async () => { Deno.test("signInCredentials with valid credentials", async () => { const csrfToken = await createCSRFToken() + const clientId = await createClientId() const response = await handler( new Request("http://localhost:3000/api/auth/signIn/credentials", { method: "POST", headers: { "Content-Type": "application/json", - Cookie: `aura-auth.csrf_token=${csrfToken}`, + Cookie: `aura-auth.csrf_token=${csrfToken}; aura-auth.client_id_token=${clientId}`, "X-CSRF-Token": csrfToken, }, body: JSON.stringify({ @@ -72,12 +77,13 @@ Deno.test("signInCredentials with valid credentials", async () => { Deno.test("signInCredentials with invalid credentials", async () => { const csrfToken = await createCSRFToken() + const clientId = await createClientId() const response = await handler( new Request("http://localhost:3000/api/auth/signIn/credentials", { method: "POST", headers: { "Content-Type": "application/json", - Cookie: `aura-auth.csrf_token=${csrfToken}`, + Cookie: `aura-auth.csrf_token=${csrfToken}; aura-auth.client_id_token=${clientId}`, "X-CSRF-Token": csrfToken, }, body: JSON.stringify({ @@ -93,12 +99,13 @@ Deno.test("signInCredentials with invalid credentials", async () => { Deno.test("signInCredentials with missing fields", async () => { const csrfToken = await createCSRFToken() + const clientId = await createClientId() const response = await handler( new Request("http://localhost:3000/api/auth/signIn/credentials", { method: "POST", headers: { "Content-Type": "application/json", - Cookie: `aura-auth.csrf_token=${csrfToken}`, + Cookie: `aura-auth.csrf_token=${csrfToken}; aura-auth.client_id_token=${clientId}`, "X-CSRF-Token": csrfToken, }, body: JSON.stringify({ diff --git a/packages/react/vitest.config.ts b/packages/react/vitest.config.ts index b174222a..74d2eaae 100644 --- a/packages/react/vitest.config.ts +++ b/packages/react/vitest.config.ts @@ -15,8 +15,8 @@ export default defineConfig({ }, resolve: { alias: { - "@": path.resolve(__dirname, "./src"), - "@test": path.resolve(__dirname, "./test"), + "@": path.resolve(import.meta.dirname, "./src"), + "@test": path.resolve(import.meta.dirname, "./test"), }, }, })