Skip to content

Commit 73ce9c3

Browse files
lws-teamclaude
andcommitted
lws-login: log why the status probe found no usable JWT
A browser showing the "Not logged in" widget on a device that cannot be inspected (an Android tablet) is undiagnosable today: the status endpoint silently answers logged_in:0 and nothing on the server says what the request actually carried. lws_jwt_auth_create() only ever consults the first cookie of the configured name, but browsers legitimately hold several same-named cookies at once (host-only alongside Domain=, or leftovers minted under an earlier cookie-domain config), ordered oldest-first per RFC 6265. A stale first cookie then shadows a live one behind it, and since every renewal and login re-mints the other scope, the widget stays "Not logged in" until the stale one ages out. Meanwhile lws-login mints its renewed cookie with Max-Age jwt-validity-secs (24h default) although the JWT inside expires on the auth server's schedule, so such a stale cookie can outlive its JWT by a day. When the .lws-login-status probe finds no usable JWT, walk every same-named cookie with lws_http_cookie_get_nth(), verify each against jwt-jwk and log at notice, attributed to the wsi: no Cookie header at all; no cookie of our name (listing the names and value lengths that were sent); per cookie, signature failure, or sub and live / expired-for-how-long; and an explicit callout when more than one same-named cookie was presented. Only the status probe emits this, since it is the one request a not-logged-in widget always makes and scanners never do. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 7f3d240 commit 73ce9c3

2 files changed

Lines changed: 160 additions & 0 deletions

File tree

plugins/protocol_lws_login/protocol_lws_login.c

Lines changed: 136 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1278,6 +1278,139 @@ simple_response(struct lws *wsi, struct pss_login *pss, const char *msg, const c
12781278
return 0;
12791279
}
12801280

1281+
1282+
/*
1283+
* Explain, at notice level, why this request did not yield a usable JWT in
1284+
* terms of what the browser actually presented. Only emitted for the
1285+
* widget's .lws-login-status probe: that is the one request a "Not logged
1286+
* in" widget always makes, and bots scanning the mount never do, so it
1287+
* cannot become log spam.
1288+
*
1289+
* lws_jwt_auth_create() only ever looks at the FIRST cookie of the configured
1290+
* name, but browsers legitimately hold several same-named cookies at once
1291+
* (host-only alongside Domain=, or leftovers minted under an earlier
1292+
* cookie-domain config), ordered oldest-first per RFC 6265, so a stale value
1293+
* can shadow a live one sitting behind it in the same Cookie header. Walk
1294+
* every occurrence and verify each against our JWK, so the log says which of
1295+
* "no cookie", "signature does not verify", "expired" or "shadowed by a
1296+
* stale duplicate" applies. None of that is observable on the device (eg a
1297+
* tablet), and without it the not-logged-in widget is undiagnosable.
1298+
*/
1299+
static void
1300+
lws_login_diag_jar(struct lws *wsi, struct vhd_login *vhd)
1301+
{
1302+
uint64_t now = (uint64_t)lws_now_secs();
1303+
int n = 0, ck_len, expired = 0;
1304+
1305+
ck_len = lws_hdr_total_length(wsi, WSI_TOKEN_HTTP_COOKIE);
1306+
if (ck_len <= 0) {
1307+
lwsl_wsi_notice(wsi, "status probe: no JWT: request carries no "
1308+
"Cookie header at all");
1309+
return;
1310+
}
1311+
1312+
for (;;) {
1313+
char jwt[8192], temp[2048], out[2048], sub[64];
1314+
size_t jwt_len = sizeof(jwt), out_len = sizeof(out), alen;
1315+
const char *v;
1316+
uint64_t exp = 0;
1317+
1318+
if (lws_http_cookie_get_nth(wsi, vhd->cookie_name, n, jwt,
1319+
&jwt_len)) {
1320+
if (n)
1321+
break;
1322+
1323+
/*
1324+
* No cookie of our name at all: say which names the
1325+
* browser did send (names and value lengths only, the
1326+
* values may be credentials for something else)
1327+
*/
1328+
{
1329+
char names[512], *ck, *q, *e;
1330+
int nl = 0;
1331+
1332+
ck = malloc((size_t)ck_len + 1);
1333+
if (!ck)
1334+
return;
1335+
if (lws_hdr_copy(wsi, ck, ck_len + 1,
1336+
WSI_TOKEN_HTTP_COOKIE) <= 0) {
1337+
free(ck);
1338+
return;
1339+
}
1340+
names[0] = '\0';
1341+
q = ck;
1342+
while (q && *q) {
1343+
while (*q == ' ' || *q == ';')
1344+
q++;
1345+
e = strchr(q, ';');
1346+
if (e)
1347+
*e++ = '\0';
1348+
v = strchr(q, '=');
1349+
if (*q)
1350+
nl += lws_snprintf(names + nl,
1351+
sizeof(names) - (size_t)nl,
1352+
"%s%.*s(%d)", nl ? ", " : "",
1353+
v ? (int)(v - q) : (int)strlen(q),
1354+
q, v ? (int)strlen(v + 1) : 0);
1355+
q = e;
1356+
if ((size_t)nl >= sizeof(names) - 8)
1357+
break;
1358+
}
1359+
free(ck);
1360+
lwsl_wsi_notice(wsi, "status probe: no JWT: no "
1361+
"'%s' cookie in a %d-byte Cookie header "
1362+
"(cookies presented: %s)",
1363+
vhd->cookie_name, ck_len, names);
1364+
}
1365+
return;
1366+
}
1367+
1368+
if (lws_jwt_signed_validate(lws_get_context(wsi), &vhd->jwk,
1369+
"ES256,ES384,ES512,RS256,RS384,RS512,HS256", jwt,
1370+
jwt_len, temp, sizeof(temp), out, &out_len)) {
1371+
lwsl_wsi_notice(wsi, "status probe: '%s' cookie #%d "
1372+
"(%u bytes): signature does not verify against "
1373+
"jwt-jwk", vhd->cookie_name, n,
1374+
(unsigned int)jwt_len);
1375+
n++;
1376+
continue;
1377+
}
1378+
1379+
v = lws_json_simple_find(out, out_len, "\"exp\":", &alen);
1380+
if (v)
1381+
exp = (uint64_t)atoll(v);
1382+
sub[0] = '\0';
1383+
v = lws_json_simple_find(out, out_len, "\"sub\":", &alen);
1384+
if (v)
1385+
lws_strnncpy(sub, v, alen, sizeof(sub));
1386+
1387+
if (!exp || exp <= now) {
1388+
expired++;
1389+
lwsl_wsi_notice(wsi, "status probe: '%s' cookie #%d: "
1390+
"sub '%s' EXPIRED %llus ago (exp %llu)",
1391+
vhd->cookie_name, n, sub,
1392+
(unsigned long long)(exp ? now - exp : 0),
1393+
(unsigned long long)exp);
1394+
} else
1395+
lwsl_wsi_notice(wsi, "status probe: '%s' cookie #%d: "
1396+
"sub '%s' live for another %llus",
1397+
vhd->cookie_name, n, sub,
1398+
(unsigned long long)(exp - now));
1399+
n++;
1400+
}
1401+
1402+
if (n > 1)
1403+
lwsl_wsi_notice(wsi, "status probe: %d same-named '%s' cookies "
1404+
"presented but only #0 is ever consulted: a stale #0 "
1405+
"shadows any live one behind it (host-only vs Domain= "
1406+
"scope, or a leftover from an earlier cookie-domain "
1407+
"config)", n, vhd->cookie_name);
1408+
else if (expired)
1409+
lwsl_wsi_notice(wsi, "status probe: single expired '%s' JWT, "
1410+
"expect the widget's silent renewal to re-mint it next",
1411+
vhd->cookie_name);
1412+
}
1413+
12811414
/*
12821415
* A "global admin" is solely the holder of the "*" wildcard grant -- the
12831416
* established "god" grant, the TOFU bootstrap account that can manage every
@@ -1922,6 +2055,9 @@ callback_lws_login(struct lws *wsi, enum lws_callback_reasons reason,
19222055
}
19232056
}
19242057

2058+
if (!pss->ja && lws_login_ends_with(path, "/.lws-login-status"))
2059+
lws_login_diag_jar(wsi, vhd);
2060+
19252061
if (pss->ja) {
19262062
int level = lws_jwt_auth_query_grant(pss->ja, service_name);
19272063
if (level >= vhd->min_grant_level) {

plugins/protocol_lws_login/protocol_lws_login.md

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -213,3 +213,27 @@ expired, 1 not in db, 0 live` — browsers legitimately hold several
213213
`auth_refresh_session` values at once, ordered oldest-first, so resolution
214214
walks them all rather than trusting the first). CSRF double-submit failures
215215
log which half was missing and the lengths, never the values themselves.
216+
217+
### Status probe diagnostics
218+
219+
A "Not logged in" widget on a device you cannot inspect (a tablet, a phone)
220+
is otherwise a dead end: the only thing that decides that state is the JWT
221+
cookie the browser presented, and the browser will not show you it. So when
222+
the widget's `.lws-login-status` probe finds no usable JWT, the bouncer logs
223+
at `notice` exactly what the request carried, attributed to the wsi:
224+
225+
- no `Cookie` header at all, or none of the configured `cookie-name`
226+
(listing the cookie names and value lengths that *were* sent);
227+
- for **every** same-named cookie in the header, whether its signature
228+
verifies against `jwt-jwk`, and if so its `sub` and whether it is live or
229+
how long ago it expired.
230+
231+
The second point matters because `lws_jwt_auth_create()` only ever consults
232+
the first cookie of that name, while browsers legitimately hold several at
233+
once (host-only alongside `Domain=`, or leftovers minted under an earlier
234+
`cookie-domain` config) ordered oldest-first, so a stale first cookie can
235+
shadow a live one behind it: every renewal and login re-mints the *other*
236+
scope and the widget stays "Not logged in" until the stale one ages out.
237+
The probe log calls that case out explicitly. Only the status probe emits
238+
these lines, since it is the one request a not-logged-in widget always makes
239+
and scanners never do.

0 commit comments

Comments
 (0)