Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 12 additions & 0 deletions api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,18 @@ QUERY_RATE_LIMIT_STRIKE_DECAY_MS=600000
# traffic shares one bucket (the proxy's IP).
TRUST_PROXY=false

# In-memory per-token auth identity cache. Default OFF — enable per environment to skip
# the full identity resolve (JWT verify + user lookups + lastLogin write) on repeat
# requests with the same token. The effective TTL is min(IDENTITY_CACHE_TTL_MS, the
# token's own exp), so a cached identity is never served past expiry. MAX_ENTRIES is an
# OOM guardrail (size to peak concurrent active tokens per API instance).
# Group membership changes and user deletion invalidate the cache promptly. The residual
# staleness window is bounded by the TTL — pick a lower TTL where faster propagation of
# token revocation at the IdP or a provider reassignment matters more than the cache hit rate.
IDENTITY_CACHE_ENABLED=false
IDENTITY_CACHE_TTL_MS=300000
IDENTITY_CACHE_MAX_ENTRIES=50000

# Mapping between Auth0 Javascript Web Token data and group and user assignments. All mappings are optional.
JWT_MAPPINGS='{
"groups": {
Expand Down
2 changes: 2 additions & 0 deletions api/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { FtsSearchService } from "./endpoints/ftsSearch.service";
import { FtsSearchController } from "./endpoints/ftsSearch.controller";
import { StorageStatusController } from "./endpoints/storageStatus.controller";
import { AuthIdentityService } from "./auth/authIdentity.service";
import { IdentityCacheService } from "./auth/identityCache.service";
import { QueryRateLimiterService } from "./ratelimit/queryRateLimiter.service";

let winstonTransport: winston.transport;
Expand Down Expand Up @@ -71,6 +72,7 @@ if (!process.env.NODE_ENV || process.env.NODE_ENV === "development") {
FtsSearchService,
ChangeRequestService,
AuthIdentityService,
IdentityCacheService,
],
})
export class AppModule {}
9 changes: 6 additions & 3 deletions api/src/auth/auth.guard.spec.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,13 @@
import { AuthGuard } from "./auth.guard";
import { AuthIdentityService } from "./authIdentity.service";
import { IdentityCacheService } from "./identityCache.service";
import type { IdentityResult } from "./authIdentity.service";

describe("AuthGuard", () => {
let guard: AuthGuard;
let authIdentityService: Partial<AuthIdentityService>;
// The guard now resolves through IdentityCacheService (a passthrough to
// AuthIdentityService.resolveOrDefault when the cache is disabled); the mock
// exposes the same resolveOrDefault contract.
let authIdentityService: Partial<IdentityCacheService>;

const defaultUserDetails = {
groups: ["group-public-users"],
Expand All @@ -15,7 +18,7 @@ describe("AuthGuard", () => {
authIdentityService = {
resolveOrDefault: jest.fn(),
};
guard = new AuthGuard(authIdentityService as AuthIdentityService);
guard = new AuthGuard(authIdentityService as IdentityCacheService);
});

function createMockContext(authHeader?: string, providerId?: string) {
Expand Down
9 changes: 6 additions & 3 deletions api/src/auth/auth.guard.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { CanActivate, ExecutionContext, Injectable } from "@nestjs/common";
import { FastifyRequest } from "fastify";
import { AuthIdentityService, JwtUserDetails } from "./authIdentity.service";
import { JwtUserDetails } from "./authIdentity.service";
import { IdentityCacheService } from "./identityCache.service";

declare module "fastify" {
interface FastifyRequest {
Expand All @@ -10,14 +11,16 @@ declare module "fastify" {

@Injectable()
export class AuthGuard implements CanActivate {
constructor(private authIdentityService: AuthIdentityService) {}
// Resolve through the identity cache (transport-agnostic; a pure passthrough to
// AuthIdentityService.resolveOrDefault when the cache is disabled).
constructor(private identityCacheService: IdentityCacheService) {}

async canActivate(context: ExecutionContext): Promise<boolean> {
const request = context.switchToHttp().getRequest<FastifyRequest>();
const token = this.extractTokenFromHeader(request);
const providerId = request.headers["x-auth-provider-id"] as string;

const result = await this.authIdentityService.resolveOrDefault(token, providerId);
const result = await this.identityCacheService.resolveOrDefault(token, providerId);
request.user = result.userDetails;
return true;
}
Expand Down
11 changes: 10 additions & 1 deletion api/src/auth/authIdentity.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -465,6 +465,7 @@ describe("AuthIdentityService", () => {
// ── AuthGuard integration ────────────────────────────────────────────────────

import { AuthGuard } from "./auth.guard";
import { IdentityCacheService } from "./identityCache.service";

jest.mock("jwks-rsa", () => {
return Object.assign(
Expand Down Expand Up @@ -500,7 +501,15 @@ describe("AuthGuard (Integrated)", () => {
};

authIdentityService = new AuthIdentityService(mockJwtService, mockDbService);
guard = new AuthGuard(authIdentityService);
// The guard resolves through IdentityCacheService; with the cache disabled
// (config returns no "identityCache") it's a passthrough to the real service,
// so this still exercises the full guard → AuthIdentityService path.
const identityCacheService = new IdentityCacheService(
authIdentityService,
{ get: () => undefined } as any,
{ on: jest.fn() } as any,
);
guard = new AuthGuard(identityCacheService);
});

it("should fall back to default groups when no email in token and no user found by identity", async () => {
Expand Down
86 changes: 86 additions & 0 deletions api/src/auth/boundedTtlCache.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
import { BoundedTtlCache, BoundedTtlCacheOptions } from "./boundedTtlCache";

describe("BoundedTtlCache", () => {
let nowMs: number;
const now = () => nowMs;

const make = <V>(over: Partial<BoundedTtlCacheOptions> = {}) =>
new BoundedTtlCache<V>({ now, ...over });

beforeEach(() => {
nowMs = 0;
});

it("returns undefined for an unknown key", () => {
const c = make<string>();
expect(c.get("nope")).toBeUndefined();
});

it("returns a stored value within its TTL", () => {
const c = make<string>();
c.set("k", "v", 1000);
expect(c.get("k")).toBe("v");
});

it("expires a value once its TTL elapses, and drops it lazily on get", () => {
const c = make<string>();
c.set("k", "v", 1000);
nowMs += 1000; // expiresAt (0 + 1000) <= now → expired
expect(c.get("k")).toBeUndefined();
expect(c.size).toBe(0);
});

it("treats a non-positive ttl as a no-op (does not cache)", () => {
const c = make<string>();
c.set("k", "v", 0);
c.set("k2", "v", -5);
expect(c.get("k")).toBeUndefined();
expect(c.size).toBe(0);
});

it("overwrites an existing key without growing size", () => {
const c = make<string>();
c.set("k", "v1", 1000);
c.set("k", "v2", 1000);
expect(c.get("k")).toBe("v2");
expect(c.size).toBe(1);
});

it("sweeps expired entries when maxEntries is reached on insert", () => {
const c = make<string>({ maxEntries: 2 });
c.set("a", "v", 1000);
c.set("b", "v", 1000);
expect(c.size).toBe(2);
nowMs += 1000; // a and b now expired
c.set("c", "v", 1000); // size >= maxEntries → sweep first
expect(c.size).toBe(1);
expect(c.get("c")).toBe("v");
expect(c.get("a")).toBeUndefined();
});

it("clear() drops everything", () => {
const c = make<string>();
c.set("a", "v", 1000);
c.set("b", "v", 1000);
c.clear();
expect(c.size).toBe(0);
});

it("delete() removes a single key", () => {
const c = make<string>();
c.set("a", "v", 1000);
c.set("b", "v", 1000);
c.delete("a");
expect(c.get("a")).toBeUndefined();
expect(c.get("b")).toBe("v");
});

it("keeps keys independent", () => {
const c = make<string>();
c.set("a", "va", 1000);
c.set("b", "vb", 5000);
nowMs += 1000; // a expired, b still live
expect(c.get("a")).toBeUndefined();
expect(c.get("b")).toBe("vb");
});
});
72 changes: 72 additions & 0 deletions api/src/auth/boundedTtlCache.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
export type BoundedTtlCacheOptions = {
/** Soft cap on tracked entries; a sweep runs when exceeded. Default 50_000. */
maxEntries?: number;
/** Injectable clock (ms). Defaults to Date.now. */
now?: () => number;
};

type Entry<V> = {
value: V;
expiresAt: number;
};

/**
* Minimal in-memory key→value cache with per-entry TTL and a bounded entry count.
*
* Same shape as {@link ../ratelimit/strikeLimiter.StrikeLimiter}: no background
* timers (which would show up as open handles in tests / shutdown). Entries expire
* lazily — an expired entry is dropped on the `get()` that next touches it — and a
* `sweep()` of all expired entries runs before an insert once `maxEntries` is reached.
*
* `maxEntries` is a soft OOM guardrail, not a working-set limiter: the sweep only
* removes already-expired entries, so the map can briefly exceed the cap if every
* entry is still live. TTL is what bounds the steady-state size.
*/
export class BoundedTtlCache<V> {
private readonly maxEntries: number;
private readonly now: () => number;
private readonly entries = new Map<string, Entry<V>>();

constructor(options: BoundedTtlCacheOptions = {}) {
this.maxEntries = options.maxEntries ?? 50_000;
this.now = options.now ?? Date.now;
}

/** Return the cached value, or undefined if missing or expired (dropping it). */
get(key: string): V | undefined {
const entry = this.entries.get(key);
if (!entry) return undefined;
if (entry.expiresAt <= this.now()) {
this.entries.delete(key); // lazy eviction
return undefined;
}
return entry.value;
}

/** Cache `value` under `key` for `ttlMs`. A non-positive ttl is a no-op. */
set(key: string, value: V, ttlMs: number): void {
if (ttlMs <= 0) return;
if (!this.entries.has(key) && this.entries.size >= this.maxEntries) this.sweep();
this.entries.set(key, { value, expiresAt: this.now() + ttlMs });
}

delete(key: string): void {
this.entries.delete(key);
}

clear(): void {
this.entries.clear();
}

/** Drop every entry whose TTL has elapsed. */
sweep(): void {
const now = this.now();
for (const [key, entry] of this.entries) {
if (entry.expiresAt <= now) this.entries.delete(key);
}
}

get size(): number {
return this.entries.size;
}
}
Loading