Skip to content
Merged
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
1 change: 1 addition & 0 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions packages/auth/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"./types": "./src/types.ts",
"./apikey": "./src/apikey/index.ts",
"./apikey/validate": "./src/apikey/validate.ts",
"./middleware": "./src/middleware/index.ts",
"./roles": "./src/roles.ts",
"./permissions": "./src/permissions.ts",
"./platform-permissions": "./src/platform-permissions.ts"
Expand All @@ -26,6 +27,7 @@
"@reloop/db": "workspace:*",
"better-auth": "catalog:",
"drizzle-orm": "catalog:db",
"elysia": "catalog:backend",
"evlog": "catalog:backend"
},
"devDependencies": {
Expand Down
9 changes: 7 additions & 2 deletions packages/auth/src/apikey/validate.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
import type { RedisCache } from "@reloop/cache/redis-client";
import { db as defaultDb } from "@reloop/db/client";
import { apikey } from "@reloop/db/schema";
import { and, eq, sql } from "drizzle-orm";
import { getApiKeyCacheKey, hashApiKey } from "./helpers";

/** Minimal cache surface (satisfied by `@reloop/cache` RedisCache). */
export type ApiKeyCache = {
get<T>(key: string): Promise<T | undefined>;
set(key: string, value: unknown, ttlSeconds?: number): Promise<void>;
};

export interface ApiKeyValidationResult {
userId: string;
organizationId: string;
Expand All @@ -17,7 +22,7 @@ export interface ApiKeyValidationResult {
*/
export async function validateApiKey(
apiKey: string | null | undefined,
redis: RedisCache,
redis: ApiKeyCache,
db = defaultDb,
): Promise<ApiKeyValidationResult | null> {
if (!apiKey || typeof apiKey !== "string") return null;
Expand Down
18 changes: 18 additions & 0 deletions packages/auth/src/middleware/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
/**
* `@reloop/auth/middleware` — shared Elysia auth plugin factory.
* Not yet mounted by services (pilot migration is a later ticket).
*/

export {
extractSessionToken,
sessionTokenCacheKey,
sessionUserIndexKey,
} from "./keys";
export { type AuthPlugin, createAuthPlugin } from "./plugin";
export { resolveSession } from "./session";
export {
type AuthContext,
type AuthMiddlewareConfig,
type AuthRedis,
DEFAULT_SESSION_CACHE_TTL_SECONDS,
} from "./types";
28 changes: 28 additions & 0 deletions packages/auth/src/middleware/keys.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/**
* Cache key convention for validated sessions.
* Owned by `@reloop/auth` so every service + central eviction agree.
*/

/** Validated session context, keyed by the raw session token (not full cookie). */
export function sessionTokenCacheKey(sessionToken: string): string {
return `session:token:${sessionToken}`;
}

/** Per-user index of cached session tokens (for bulk eviction). */
export function sessionUserIndexKey(userId: string): string {
return `session:user:${userId}`;
}

/**
* Extract the Better Auth session token from a Cookie header.
* Cookie name uses the `reloop` cookiePrefix: `reloop.session_token`.
*/
export function extractSessionToken(cookie: string | null): string | null {
if (!cookie) return null;
const match = cookie.match(/(?:^|;\s*)reloop\.session_token=([^;]+)/);
if (!match?.[1]) return null;
const raw = decodeURIComponent(match[1]);
// Better Auth stores `token.signature` in the cookie value.
const token = raw.split(".")[0];
return token || null;
}
163 changes: 163 additions & 0 deletions packages/auth/src/middleware/plugin.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,163 @@
import { Elysia } from "elysia";
import { validateApiKey } from "../apikey/validate";
import { PLATFORM_ADMIN_ROLE } from "../roles";
import { resolveSession } from "./session";
import {
type AuthContext,
type AuthMiddlewareConfig,
type AuthRedis,
DEFAULT_SESSION_CACHE_TTL_SECONDS,
} from "./types";

type ResolveOpts = {
baseUrl: string;
redis: AuthRedis;
ttl: number;
requireOrg: boolean;
allowApiKey: boolean;
requirePlatformAdmin: boolean;
};

async function resolveFromHeaders(
headers: Headers,
opts: ResolveOpts,
): Promise<AuthContext | null> {
if (opts.allowApiKey) {
const apiKey =
headers.get("x-api-key") ||
headers.get("authorization")?.replace(/^Bearer\s+/i, "");
if (apiKey) {
const result = await validateApiKey(apiKey, opts.redis);
if (result) {
if (opts.requireOrg && !result.organizationId) return null;
const ctx: AuthContext = {
userId: result.userId,
organizationId: result.organizationId,
role: null,
authType: "apikey",
};
if (opts.requirePlatformAdmin) return null;
return ctx;
}
}
}

const cookie = headers.get("cookie");
const session = await resolveSession(cookie, {
baseUrl: opts.baseUrl,
redis: opts.redis,
ttl: opts.ttl,
requireOrg: opts.requireOrg && !opts.requirePlatformAdmin,
});
if (!session) return null;

if (opts.requirePlatformAdmin) {
if (session.role !== PLATFORM_ADMIN_ROLE) return null;
}

return session;
}

/**
* Shared Elysia auth plugin factory.
*
* Inject `{ baseUrl, redis, ttl }` per service — nothing is hardcoded.
* Registers four guard macros that all produce the canonical {@link AuthContext}.
*/
export function createAuthPlugin(config: AuthMiddlewareConfig) {
const baseUrl = config.baseUrl;
const redis = config.redis;
const ttl = config.ttl ?? DEFAULT_SESSION_CACHE_TTL_SECONDS;

const base = { baseUrl, redis, ttl };

return new Elysia({ name: "reloop-auth-middleware" }).macro({
/**
* Default guard: session or API key; fail closed without active org.
*/
auth: {
async resolve({ status, request: { headers } }) {
const ctx = await resolveFromHeaders(headers, {
...base,
requireOrg: true,
allowApiKey: true,
requirePlatformAdmin: false,
});
if (!ctx) {
return status(401, { message: "Authentication required" });
}
return ctx;
},
detail: {
security: [{ apiKey: [] }],
},
},

/**
* Session or API key; organization optional.
*/
authNoOrg: {
async resolve({ status, request: { headers } }) {
const ctx = await resolveFromHeaders(headers, {
...base,
requireOrg: false,
allowApiKey: true,
requirePlatformAdmin: false,
});
if (!ctx) {
return status(401, { message: "Authentication required" });
}
return ctx;
},
},

/**
* API-key only. Organization comes from the key's owning org.
*/
apiKeyAuth: {
async resolve({ status, request: { headers } }) {
const apiKey =
headers.get("x-api-key") ||
headers.get("authorization")?.replace(/^Bearer\s+/i, "");
const result = await validateApiKey(apiKey, redis);
if (!result?.organizationId) {
return status(401, { message: "Authentication required" });
}
const ctx: AuthContext = {
userId: result.userId,
organizationId: result.organizationId,
role: null,
authType: "apikey",
};
return ctx;
},
detail: {
security: [{ apiKey: [] }],
},
},

/**
* Session only; requires platform-admin role. Org optional.
*/
platformAdmin: {
async resolve({ status, request: { headers } }) {
const ctx = await resolveFromHeaders(headers, {
...base,
requireOrg: false,
allowApiKey: false,
requirePlatformAdmin: true,
});
if (!ctx) {
return status(401, {
message: "Unauthorized access",
why: "Platform admin privileges are required",
fix: "Sign in with a platform admin account",
});
}
return ctx;
},
},
Comment on lines +142 to +159

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Platform Admin Context Shrinks

This shared platformAdmin macro returns only AuthContext, while the current admin middleware also supplies fields such as email, name, isPlatformAdmin, traceId, and logger. When admin routes migrate to this macro, handlers that read those fields can receive undefined and behave incorrectly at runtime.

});
}

export type AuthPlugin = ReturnType<typeof createAuthPlugin>;
106 changes: 106 additions & 0 deletions packages/auth/src/middleware/session.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import {
extractSessionToken,
sessionTokenCacheKey,
sessionUserIndexKey,
} from "./keys";
import type { AuthContext, AuthRedis } from "./types";

type SessionUser = {
id: string;
role?: string | null;
activeOrganizationId?: string | null;
};

type GetSessionBody = {
user?: SessionUser | null;
} | null;

export type ResolveSessionOptions = {
baseUrl: string;
redis: AuthRedis;
ttl: number;
/** When true, require activeOrganizationId (fail closed). */
requireOrg: boolean;
};

/**
* Resolve a session cookie to AuthContext via cache or get-session HTTP.
* Always checks response.ok before trusting the body.
*/
export async function resolveSession(
cookie: string | null,
opts: ResolveSessionOptions,
): Promise<AuthContext | null> {
if (!cookie) return null;

const token = extractSessionToken(cookie);
if (!token) return null;

const cacheKey = sessionTokenCacheKey(token);
const cached = await opts.redis.get<AuthContext>(cacheKey);
if (cached) {
if (opts.requireOrg && !cached.organizationId) return null;
return cached;
Comment on lines +40 to +43

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security Cached Sessions Skip Revocation

When a token is already cached, this path returns the cached AuthContext without calling get-session. A revoked session, removed active organization, or changed role can keep authorizing guarded routes until the Redis entry expires, even though the auth service would reject or downgrade the session.

}

const sessionUrl = `${opts.baseUrl.replace(/\/$/, "")}/api/auth/v1/get-session`;
let response: Response;
try {
response = await fetch(sessionUrl, {
method: "GET",
headers: {
"Content-Type": "application/json",
Cookie: cookie,
},
});
} catch {
return null;
}

// Always check HTTP status before trusting the body.
if (!response.ok) return null;

let body: GetSessionBody;
try {
body = (await response.json()) as GetSessionBody;
} catch {
return null;
}

const user = body?.user;
if (!user?.id) return null;

const organizationId = user.activeOrganizationId ?? null;
if (opts.requireOrg && !organizationId) return null;

const ctx: AuthContext = {
userId: user.id,
organizationId,
role: user.role ?? null,
authType: "session",
};

// Cache + per-user index (best-effort; never block auth on cache failure).
await opts.redis.set(cacheKey, ctx, opts.ttl).catch(() => undefined);
await addTokenToUserIndex(opts.redis, user.id, token, opts.ttl).catch(
() => undefined,
);

return ctx;
}

async function addTokenToUserIndex(
redis: AuthRedis,
userId: string,
token: string,
ttl: number,
): Promise<void> {
const indexKey = sessionUserIndexKey(userId);
const existing = (await redis.get<string[]>(indexKey)) ?? [];
if (existing.includes(token)) {
// Refresh TTL on the index entry.
await redis.set(indexKey, existing, ttl);
return;
}
await redis.set(indexKey, [...existing, token], ttl);
Comment on lines +98 to +105

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 security Concurrent Index Updates Lose Tokens

Two simultaneous logins for the same user can both read the same session:user:<userId> array and then write different appended arrays. The later write drops the other token from the index, so a later bulk eviction can miss that token and leave its session:token:<token> cache entry usable until TTL expiry.

}
28 changes: 28 additions & 0 deletions packages/auth/src/middleware/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
/**
* Canonical auth context returned by every shared middleware macro.
* `organizationId` is null only on authNoOrg / platformAdmin paths.
*/
export type AuthContext = {
userId: string;
organizationId: string | null;
role: string | null;
authType: "session" | "apikey";
};

/** Minimal Redis surface used by session caching (satisfied by RedisCache). */
export type AuthRedis = {
get<T>(key: string): Promise<T | undefined>;
set(key: string, value: unknown, ttlSeconds?: number): Promise<void>;
delete(key: string): Promise<void>;
};

export type AuthMiddlewareConfig = {
/** Base URL of the auth service (e.g. https://local.reloop.sh). */
baseUrl: string;
/** Shared Redis used for short-TTL session cache + API-key cache. */
redis: AuthRedis;
/** Session-cache TTL in seconds. Default 5. */
ttl?: number;
};

export const DEFAULT_SESSION_CACHE_TTL_SECONDS = 5;
Loading