desktop: route News through the client-JWT path; bump to 0.4.4

News called /api/v1/client/news through apiGet(), which sends the
webtoken Bearer obtained from POST /api/v1/client/user/webtoken. But
that route is a defineClientEventHandler -- it verifies the clients own
short-lived signed JWT and never consults the webtokens ACLs at all, so
it rejected the request outright. Store and Library have always worked
because they already use the JWT path.

Adds api_get_jwt on the Rust side and apiGetClient() in the composable,
and points News at it. Verified working against the live server.

Two auth mechanisms coexist here and picking the wrong one yields a
clean 403 that looks like a permissions problem: /api/v1/client/* wants
the client JWT, while routes gated by aclManager.getUserIdACL want the
webtoken plus the right ACL.
This commit is contained in:
wdunn001 2026-08-03 23:46:04 -04:00
parent 79aeaa9c24
commit c7ffc2b994
5 changed files with 96 additions and 22 deletions

View file

@ -22,6 +22,26 @@ export function apiGet<T = any>(
return invoke<T>("api_get", { path, query: q });
}
// Same as apiGet, but for `/api/v1/client/*` routes built on
// `defineClientEventHandler` (news, library, game manifests, ...). Those
// routes authenticate with the desktop client's own short-lived JWT
// (Rust's generate_authorization_header), NOT the aclManager webtoken
// apiGet's api_get command mints -- sending them a webtoken 403s no matter
// what ACLs it carries, since defineClientEventHandler never looks at
// ACLs at all. See desktop/src-tauri/src/community_api.rs's module header
// for the full explanation of why there are two auth paths here.
export function apiGetClient<T = any>(
path: string,
query?: Record<string, string | number | boolean | undefined>,
): Promise<T> {
const q = query
? Object.entries(query)
.filter(([, v]) => v !== undefined)
.map(([k, v]) => [k, String(v)] as [string, string])
: undefined;
return invoke<T>("api_get_jwt", { path, query: q });
}
export function apiPost<T = any>(path: string, body?: unknown): Promise<T> {
return invoke<T>("api_post", { path, body: body ?? {} });
}

View file

@ -100,7 +100,7 @@
<script setup lang="ts">
import { NewspaperIcon, XCircleIcon } from "@heroicons/vue/20/solid";
import { micromark } from "micromark";
import { apiGet } from "~/composables/api";
import { apiGetClient } from "~/composables/api";
interface NewsAuthor {
id: string;
@ -152,9 +152,13 @@ async function load() {
error.value = undefined;
try {
// Client-facing news route (server/server/api/v1/client/news/index.get.ts)
// -- goes through the same client-auth path fetch_library/fetch_game use,
// rather than the plain user-session `api/v1/news` the marketing site uses.
articles.value = await apiGet<NewsArticle[]>("api/v1/client/news", {
// is a defineClientEventHandler route -- it wants the client's own JWT,
// the same auth fetch_library/fetch_game use, NOT the community webtoken
// apiGet() sends. apiGetClient() (api_get_jwt in Rust) is that path; see
// desktop/src-tauri/src/community_api.rs's module header. Using apiGet()
// here previously 403'd even with every community/notifications ACL
// granted, because the route never inspects webtoken ACLs at all.
articles.value = await apiGetClient<NewsArticle[]>("api/v1/client/news", {
order: "desc",
});
} catch (e) {

View file

@ -5,28 +5,42 @@
//!
//! Rather than hand-writing one typed Tauri command per server route (dozens
//! of near-identical GET/POST/DELETE wrappers for endpoints that already
//! return exactly the JSON shape the frontend wants), these three commands
//! are thin, path-driven passthroughs. The frontend passes the server's own
//! return exactly the JSON shape the frontend wants), these commands are
//! thin, path-driven passthroughs. The frontend passes the server's own
//! `/api/v1/...` path; auth, base-URL resolution, and error surfacing stay
//! in one place.
//!
//! ## Auth: this can't reuse `generate_authorization_header()`
//! ## Auth: TWO distinct mechanisms live in this one file, on purpose
//!
//! Every OTHER client call (`fetch_drop_object`, the M2 ingest pings in
//! `community.rs`) signs requests with a short-lived JWT
//! Every OTHER client call (`fetch_drop_object`, `fetch_library`, the M2
//! ingest pings in `community.rs`) signs requests with a short-lived JWT
//! (`generate_authorization_header`), verified server-side by
//! `defineClientEventHandler`'s client-certificate check. Notifications and
//! the M3 community routes (friends/chat/presence/profile/achievements) are
//! gated by `aclManager.getUserIdACL` instead, which only understands a
//! browser session cookie or an opaque `Bearer <APIToken.token>` -- it does
//! a literal `prisma.aPIToken.findUnique({ where: { token } })` lookup, so
//! the JWT (a fresh, ~60-char signed string that is never a row in that
//! table) always misses and the route 403s. Confirmed by running the built
//! client against the live server before this file added the exchange
//! below: `GET /api/v1/notifications` and `GET /api/v1/community/friends`
//! both came back "403, Server Error" through this exact bridge, while
//! `GET /api/v1/client/news` (a `defineClientEventHandler` route, JWT-only)
//! worked immediately.
//! `defineClientEventHandler`'s client-certificate check. That check never
//! looks at `aclManager`/ACLs at all -- it's a different auth mechanism, not
//! a differently-scoped one. `/api/v1/client/*` routes (news, library,
//! game manifests, ...) are built on `defineClientEventHandler` and want
//! this JWT; sending them a webtoken Bearer instead 403s regardless of that
//! token's ACLs. `api_get_jwt` below is that path -- use it for
//! `/api/v1/client/*`.
//!
//! Notifications and the M3 community routes (friends/chat/presence/
//! profile/achievements) are gated by `aclManager.getUserIdACL` instead,
//! which only understands a browser session cookie or an opaque
//! `Bearer <APIToken.token>` -- it does a literal
//! `prisma.aPIToken.findUnique({ where: { token } })` lookup, so the JWT (a
//! fresh, ~60-char signed string that is never a row in that table) always
//! misses and the route 403s. Confirmed by running the built client against
//! the live server before this file added the exchange below:
//! `GET /api/v1/notifications` and `GET /api/v1/community/friends` both
//! came back "403, Server Error" through the JWT path, while
//! `/api/v1/community/*` and `/api/v1/notifications*` want the webtoken
//! Bearer minted below (`api_get`/`api_post`/`api_delete`).
//!
//! Mixing these up is a silent 403 either way (both mechanisms return the
//! same shape of error), so **check which handler a route uses --
//! `defineClientEventHandler` vs `aclManager.getUserIdACL` -- before wiring
//! a new call site**, rather than assuming every server route accepts
//! whichever credential is already in hand.
//!
//! The bridge is `POST /api/v1/client/user/webtoken`
//! (`server/server/api/v1/client/user/webtoken.post.ts`) -- itself a
@ -49,6 +63,7 @@
use std::sync::{LazyLock, nonpoison::Mutex};
use remote::{
auth::generate_authorization_header,
error::{DropServerError, RemoteAccessError},
requests::{generate_url, make_authenticated_post_json},
utils::DROP_CLIENT_ASYNC,
@ -183,6 +198,40 @@ pub async fn api_delete(path: String) -> Result<Value, RemoteAccessError> {
parse_response(resp).await
}
/// GET `{drop-server}/{path}` authenticated with the client's own short-lived
/// JWT (`generate_authorization_header`), NOT the webtoken `api_get` above
/// uses. For `defineClientEventHandler` routes -- e.g.
/// `/api/v1/client/news` -- which verify that JWT directly and don't go
/// through `aclManager.getUserIdACL` at all, so ACLs (including
/// `CLIENT_WEBTOKEN_ACLS`) are irrelevant to them and a webtoken Bearer
/// token 403s no matter what scopes it carries. Mirrors the auth
/// `fetch_library`/`fetch_game` (games.rs) already use for the same route
/// family, kept as a generic path-driven passthrough like `api_get` instead
/// of a one-off typed command since the frontend just wants the JSON body.
///
/// Confirmed live: routing `desktop/main/pages/news.vue` through `api_get`
/// (webtoken) 403'd even after the server granted every community/
/// notifications ACL to `CLIENT_WEBTOKEN_ACLS` -- because `/api/v1/client/
/// news` never looks at the webtoken's ACLs in the first place, it rejects
/// the credential type outright. Switching that one call site to this
/// command instead is the actual fix.
#[tauri::command]
pub async fn api_get_jwt(
path: String,
query: Option<Vec<(String, String)>>,
) -> Result<Value, RemoteAccessError> {
let query = query.unwrap_or_default();
let query_refs: Vec<(&str, &str)> =
query.iter().map(|(k, v)| (k.as_str(), v.as_str())).collect();
let url = generate_url(&[path.as_str()], &query_refs)?;
let resp = DROP_CLIENT_ASYNC
.get(url)
.header("Authorization", generate_authorization_header())
.send()
.await?;
parse_response(resp).await
}
/// Exposed so `community_ws.rs` can authenticate the websocket handshake
/// with the same webtoken (that route is also `aclManager`-gated, not
/// JWT-based -- see this module's header comment).

View file

@ -255,6 +255,7 @@ pub fn run() {
// notifications) -- generic authenticated REST bridge + the
// community websocket bridge, see community_api.rs/community_ws.rs.
api_get,
api_get_jwt,
api_post,
api_delete,
community_ws_connect,

View file

@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2.0.0",
"productName": "Drop Desktop Client (Quasarke Edition)",
"version": "0.4.3",
"version": "0.4.4",
"identifier": "net.quasarke.drop.client",
"build": {
"beforeDevCommand": "pnpm run -C main dev --port 1432",