feat(api): JWT session auth for models endpoint + refactor (v1.2.0)

- Merged PR #110 by @nyatoru: JWT session auth fallback for /v1/models
- Refactored: removed ~60 lines of same-origin detection (sameSite:lax already prevents CSRF)
- Changed auth failure response: 404 Not Found -> 401 Unauthorized (OpenAI format)
- Version bumped to v1.2.0

Closes #110
This commit is contained in:
diegosouzapw
2026-02-22 18:36:57 -03:00
parent 62c634ae78
commit a17583d3fc
3 changed files with 33 additions and 65 deletions

View File

@@ -7,6 +7,23 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
---
## [1.2.0] — 2026-02-22
> ### ✨ Feature Release — Dashboard Session Auth for Models Endpoint
>
> Dashboard users can now access `/v1/models` via their existing session when API key auth is required.
### ✨ New Features
- **JWT Session Auth Fallback** — When `requireAuthForModels` is enabled, the `/v1/models` endpoint now accepts both API key (Bearer token) for external clients **and** the dashboard JWT session cookie (`auth_token`), allowing logged-in dashboard users to view models without needing an explicit API key ([PR #110](https://github.com/diegosouzapw/OmniRoute/pull/110) by [@nyatoru](https://github.com/nyatoru))
### 🔧 Improvements
- **401 instead of 404** — Authentication failures on `/v1/models` now return `401 Unauthorized` with a structured JSON error body (OpenAI-compatible format) instead of a generic `404 Not Found`, improving debuggability for API clients
- **Simplified auth logic** — Refactored the JWT cookie verification to reuse the same pattern as `apiAuth.ts`, removing redundant same-origin detection (~60 lines) since the `sameSite:lax` + `httpOnly` cookie flags already provide equivalent CSRF protection
---
## [1.1.1] — 2026-02-22
> ### 🐛 Bugfix Release — API Key Creation & Codex Team Plan Quotas
@@ -450,6 +467,7 @@ New environment variables:
---
[1.2.0]: https://github.com/diegosouzapw/OmniRoute/releases/tag/v1.2.0
[1.1.1]: https://github.com/diegosouzapw/OmniRoute/releases/tag/v1.1.1
[1.0.7]: https://github.com/diegosouzapw/OmniRoute/releases/tag/v1.0.7
[1.0.6]: https://github.com/diegosouzapw/OmniRoute/releases/tag/v1.0.6

View File

@@ -1,6 +1,6 @@
{
"name": "omniroute",
"version": "1.1.1",
"version": "1.2.0",
"description": "Smart AI Router with auto fallback — route to FREE & cheap models, zero downtime. Works with Cursor, Cline, Claude Desktop, Codex, and any OpenAI-compatible tool.",
"type": "module",
"bin": {

View File

@@ -108,6 +108,7 @@ export async function GET(request: Request) {
} catch {}
if (settings.requireAuthForModels === true) {
// Check authentication: API key OR dashboard session (JWT cookie)
// Supports dual auth: Bearer token for external clients, cookie for dashboard.
let isAuthenticated = false;
// 1. Check API key (for external clients)
@@ -116,72 +117,21 @@ export async function GET(request: Request) {
isAuthenticated = true;
}
// 2. Check JWT cookie (ONLY for dashboard requests - same origin)
// External API clients must use API key authentication
// 2. Check JWT cookie (for dashboard session)
// The auth_token cookie has sameSite:lax + httpOnly, which already
// prevents cross-origin abuse — no additional origin check needed.
// Same pattern as shared/utils/apiAuth.ts verifyAuth().
if (!isAuthenticated && process.env.JWT_SECRET) {
const origin = request.headers.get("origin");
const referer = request.headers.get("referer");
const host = request.headers.get("host");
const secFetchSite = request.headers.get("sec-fetch-site");
// Check if request is from dashboard (same origin)
// Security: Use strict matching instead of loose includes()
let isDashboardRequest = false;
// Check sec-fetch-site header (set by browser, harder to spoof)
// "same-origin" = same origin request, "none" = same origin navigation
if (secFetchSite === "same-origin" || secFetchSite === "none") {
isDashboardRequest = true;
}
// Fallback: Check origin/referer against host with proper URL parsing
if (!isDashboardRequest && host) {
const normalizeHost = (h: string) => {
// Remove port if present for comparison
const colonIndex = h.lastIndexOf(":");
return colonIndex > 0 ? h.slice(0, colonIndex) : h;
};
const normalizedHost = normalizeHost(host);
if (origin) {
try {
const originUrl = new URL(origin);
const normalizedOrigin = normalizeHost(originUrl.host);
// Exact match only
if (normalizedOrigin === normalizedHost) {
isDashboardRequest = true;
}
} catch {
// Invalid URL, not a dashboard request
}
}
if (!isDashboardRequest && referer) {
try {
const refererUrl = new URL(referer);
const normalizedReferer = normalizeHost(refererUrl.host);
// Exact match only
if (normalizedReferer === normalizedHost) {
isDashboardRequest = true;
}
} catch {
// Invalid URL, not a dashboard request
}
}
}
if (isDashboardRequest) {
try {
const cookieStore = await cookies();
const token = cookieStore.get("auth_token")?.value;
if (token) {
const secret = new TextEncoder().encode(process.env.JWT_SECRET);
await jwtVerify(token, secret);
isAuthenticated = true;
}
} catch {
// Invalid/expired token or cookies not available — not authenticated
try {
const cookieStore = await cookies();
const token = cookieStore.get("auth_token")?.value;
if (token) {
const secret = new TextEncoder().encode(process.env.JWT_SECRET);
await jwtVerify(token, secret);
isAuthenticated = true;
}
} catch {
// Invalid/expired token or cookies not available — not authenticated
}
}