Skip to content

Commit 3c6a860

Browse files
lws-teamclaude
andcommitted
jwt-auth: walk same-named cookies and prefer a live one
lws_jwt_auth_create() took only the first cookie of the configured name. Browsers legitimately present several same-named cookies at once (host-only alongside Domain=, or a leftover minted under an earlier cookie-domain config), ordered oldest-first per RFC 6265, so a stale first cookie shadowed a live one behind it. Since every renewal and login re-mints the *other* scope, the user was stuck "Not logged in" until the stale cookie aged out, which is what an Android tablet showed against a server whose lws-login cookie config had recently changed. The auth server already resolved its auth_refresh_session this way; the JWT side now does too. Walk every occurrence (occurrence 0 via the prefix-aware lookup so the __Host- / __Secure- aliases still work) and return the first that verifies and is unexpired. If none is live, return the first that verified, so callers keep deciding what an expired token means exactly as before; NULL only when nothing verified. An oversized occurrence is skipped rather than ending the search. Stop such stale duplicates outliving their token: lws-login minted its renewed auth_session with Max-Age jwt-validity-secs (24h default) although the JWT inside expires on the auth server's schedule, so a dead cookie could sit in the jar a day after it stopped verifying. Cap every auth_session this plugin mints at the token's own remaining exp; unverifiable or exp-less tokens fall back to jwt-validity-secs as before. The status probe diagnostic no longer claims only #0 is consulted. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
1 parent 73ce9c3 commit 3c6a860

4 files changed

Lines changed: 146 additions & 54 deletions

File tree

include/libwebsockets/lws-jwt-auth.h

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,13 @@ typedef int (*lws_jwt_auth_cb_t)(struct lws_jwt_auth *ja, int state, void *user)
3939
* executes lightweight lejp JSON parsing to extract the exp timestamp and ANY custom grants dictionaries,
4040
* allocates the tracking object, and registers the proactive SUL timer natively.
4141
*
42-
* Returns NULL on failure, or the allocated opaque object on a successful verification.
42+
* Browsers can present several same-named cookies at once (host-only
43+
* alongside Domain=, or a leftover from an earlier cookie scope), oldest
44+
* first. Every occurrence is tried: the first one that verifies and is
45+
* unexpired wins. If none is live, the first that verified is returned so
46+
* the caller decides what an expired token means (check lws_jwt_auth_get_exp()).
47+
*
48+
* Returns NULL if no occurrence verified, or the allocated opaque object.
4349
*/
4450
LWS_VISIBLE LWS_EXTERN struct lws_jwt_auth *
4551
lws_jwt_auth_create(struct lws *wsi, struct lws_jwk *jwk,

lib/jose/jws/jwt-auth.c

Lines changed: 75 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -210,38 +210,88 @@ lws_jwt_auth_create(struct lws *wsi, struct lws_jwk *jwk,
210210
lws_jwt_auth_cb_t cb, void *user,
211211
const char **reason)
212212
{
213+
struct lws_jwt_auth *ja = NULL, *cand;
214+
uint64_t now = (uint64_t)lws_now_secs();
215+
const char *r = NULL, *cr;
213216
char jwt[8192];
214-
size_t jwt_len = sizeof(jwt);
215-
struct lws_jwt_auth *ja;
216-
int n;
217+
size_t jwt_len;
218+
int n = 0, m;
219+
220+
/*
221+
* Browsers legitimately present several same-named cookies at once
222+
* (host-only alongside Domain=, or a leftover minted under an earlier
223+
* cookie-domain config), ordered oldest-first per RFC 6265. Taking
224+
* only the first lets a stale one shadow a live one sitting behind it
225+
* in the same header, and since every renewal and login re-mints the
226+
* *other* scope, the user stays "not logged in" until the stale cookie
227+
* ages out. So walk every occurrence and take the first that
228+
* verifies and is unexpired. If none is live, return the first that
229+
* verified: what an expired token means is the caller's decision,
230+
* exactly as before. NULL only when nothing verified at all.
231+
*/
232+
for (;;) {
233+
jwt_len = sizeof(jwt);
234+
if (!n)
235+
/*
236+
* occurrence 0 via the prefix-aware lookup, so the
237+
* __Host- / __Secure- aliases keep working when no
238+
* plain-named cookie exists
239+
*/
240+
m = lws_http_cookie_get(wsi, cookie_name, jwt, &jwt_len);
241+
else
242+
m = lws_http_cookie_get_nth(wsi, cookie_name, n, jwt,
243+
&jwt_len);
244+
if (m) {
245+
if (!r)
246+
r = m == 2 ? "Cookie value too large for buffer" :
247+
"Cookie not found";
248+
if (m != 2)
249+
break; /* no more occurrences */
250+
n++; /* oversized: skip it, look behind it */
251+
continue;
252+
}
217253

218-
n = lws_http_cookie_get(wsi, cookie_name, jwt, &jwt_len);
219-
if (n) {
220-
if (reason)
221-
*reason = n == 2 ? "Cookie value too large for buffer" :
222-
"Cookie not found";
223-
return NULL;
224-
}
254+
cand = lws_zalloc(sizeof(*cand), __func__);
255+
if (!cand) {
256+
r = "OOM";
257+
break;
258+
}
225259

226-
ja = lws_zalloc(sizeof(*ja), __func__);
227-
if (!ja) {
228-
if (reason)
229-
*reason = "OOM";
230-
return NULL;
231-
}
260+
cand->cx = lws_get_context(wsi);
261+
cand->wsi = wsi;
262+
cand->jwk = jwk;
263+
cand->cb = cb;
264+
cand->user = user;
265+
lws_strncpy(cand->cookie_name, cookie_name,
266+
sizeof(cand->cookie_name));
267+
268+
cr = NULL;
269+
if (lws_jwt_auth_update(cand, jwt, &cr)) {
270+
if (!r)
271+
r = cr;
272+
lws_jwt_auth_destroy(&cand);
273+
n++;
274+
continue;
275+
}
232276

233-
ja->cx = lws_get_context(wsi);
234-
ja->wsi = wsi;
235-
ja->jwk = jwk;
236-
ja->cb = cb;
237-
ja->user = user;
238-
lws_strncpy(ja->cookie_name, cookie_name, sizeof(ja->cookie_name));
277+
if (!cand->exp || cand->exp > now) {
278+
/* live: this is the one, drop any expired fallback */
279+
if (ja)
280+
lws_jwt_auth_destroy(&ja);
281+
ja = cand;
282+
break;
283+
}
239284

240-
if (lws_jwt_auth_update(ja, jwt, reason)) {
241-
lws_free(ja);
242-
return NULL;
285+
if (!ja)
286+
ja = cand; /* verified but expired: fallback only */
287+
else
288+
lws_jwt_auth_destroy(&cand);
289+
n++;
243290
}
244291

292+
if (!ja && reason)
293+
*reason = r ? r : "Cookie not found";
294+
245295
return ja;
246296
}
247297

plugins/protocol_lws_login/protocol_lws_login.c

Lines changed: 53 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -750,6 +750,41 @@ lws_login_kick_refresh_selfheal(struct vhd_login *vhd, struct lws *wsi,
750750
return kicked;
751751
}
752752

753+
/*
754+
* Max-Age for a cookie carrying token: the smaller of jwt-validity-secs and
755+
* the token's own remaining lifetime. A cookie that outlives the JWT inside
756+
* it is pure liability: it can only ever fail verification, and if the
757+
* browser orders it ahead of a live same-named cookie (host-only vs Domain=
758+
* scope) it shadows that one. A token we cannot verify or that carries no
759+
* exp falls back to jwt-validity-secs, exactly as before.
760+
*/
761+
static unsigned long long
762+
lws_login_token_max_age(struct lws *wsi, struct vhd_login *vhd,
763+
const char *token)
764+
{
765+
uint64_t now = (uint64_t)lws_now_secs(), exp;
766+
char temp[2048], out[2048];
767+
size_t out_len = sizeof(out), alen;
768+
const char *v;
769+
770+
if (lws_jwt_signed_validate(lws_get_context(wsi), &vhd->jwk,
771+
"ES256,ES384,ES512,RS256,RS384,RS512,HS256", token,
772+
strlen(token), temp, sizeof(temp), out, &out_len))
773+
return (unsigned long long)vhd->jwt_validity_secs;
774+
775+
v = lws_json_simple_find(out, out_len, "\"exp\":", &alen);
776+
if (!v)
777+
return (unsigned long long)vhd->jwt_validity_secs;
778+
779+
exp = (uint64_t)atoll(v);
780+
if (exp <= now + 1)
781+
return 1;
782+
if (exp - now < vhd->jwt_validity_secs)
783+
return (unsigned long long)(exp - now);
784+
785+
return (unsigned long long)vhd->jwt_validity_secs;
786+
}
787+
753788
/*
754789
* Serve the protected page to the browser by re-issuing the JWT cookie and
755790
* 302-ing back to the same URL the browser originally asked for. Used by:
@@ -791,8 +826,7 @@ lws_login_serve_self_redirect_with_cookie(struct lws *wsi, struct pss_login *pss
791826
if (lws_http_cookie_compose(cookie, sizeof(cookie),
792827
vhd->cookie_name, token,
793828
vhd->cookie_domain,
794-
(unsigned long long)
795-
vhd->jwt_validity_secs,
829+
lws_login_token_max_age(wsi, vhd, token),
796830
NULL) < 0) {
797831
lwsl_wsi_err(wsi, "%s: %s Set-Cookie too large for the "
798832
"composed buffer (token %d, domain %d)",
@@ -1286,15 +1320,15 @@ simple_response(struct lws *wsi, struct pss_login *pss, const char *msg, const c
12861320
* in" widget always makes, and bots scanning the mount never do, so it
12871321
* cannot become log spam.
12881322
*
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.
1323+
* Browsers legitimately hold several same-named cookies at once (host-only
1324+
* alongside Domain=, or leftovers minted under an earlier cookie-domain
1325+
* config), ordered oldest-first per RFC 6265. lws_jwt_auth_create() walks
1326+
* them all and prefers a live one, but a jar full of stale duplicates is
1327+
* still worth knowing about, so walk every occurrence and verify each
1328+
* against our JWK: the log then says which of "no cookie", "signature does
1329+
* not verify" or "expired" applies, per cookie. None of that is observable
1330+
* on the device (eg a tablet), and without it the not-logged-in widget is
1331+
* undiagnosable.
12981332
*/
12991333
static void
13001334
lws_login_diag_jar(struct lws *wsi, struct vhd_login *vhd)
@@ -1401,10 +1435,9 @@ lws_login_diag_jar(struct lws *wsi, struct vhd_login *vhd)
14011435

14021436
if (n > 1)
14031437
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);
1438+
"presented, none live (host-only vs Domain= scope, or "
1439+
"a leftover from an earlier cookie-domain config)",
1440+
n, vhd->cookie_name);
14081441
else if (expired)
14091442
lwsl_wsi_notice(wsi, "status probe: single expired '%s' JWT, "
14101443
"expect the widget's silent renewal to re-mint it next",
@@ -2729,8 +2762,9 @@ callback_lws_login(struct lws *wsi, enum lws_callback_reasons reason,
27292762
vhd->cookie_name,
27302763
pss->silent_update_jwt,
27312764
vhd->cookie_domain,
2732-
(unsigned long long)
2733-
vhd->jwt_validity_secs,
2765+
lws_login_token_max_age(
2766+
wsi, vhd,
2767+
pss->silent_update_jwt),
27342768
NULL) < 0) {
27352769
lwsl_wsi_err(wsi, "%s: %s "
27362770
"Set-Cookie too "
@@ -2949,8 +2983,8 @@ callback_lws_login(struct lws *wsi, enum lws_callback_reasons reason,
29492983
vhd->cookie_name,
29502984
ps->token,
29512985
vhd->cookie_domain,
2952-
(unsigned long long)
2953-
vhd->jwt_validity_secs,
2986+
lws_login_token_max_age(
2987+
wsi, vhd, ps->token),
29542988
NULL);
29552989
if (n < 0) {
29562990
lwsl_wsi_err(wsi, "%s: %s Set-Cookie "

plugins/protocol_lws_login/protocol_lws_login.md

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -228,12 +228,14 @@ at `notice` exactly what the request carried, attributed to the wsi:
228228
verifies against `jwt-jwk`, and if so its `sub` and whether it is live or
229229
how long ago it expired.
230230

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.
231+
The second point matters because browsers legitimately hold several
232+
same-named cookies at once (host-only alongside `Domain=`, or leftovers
233+
minted under an earlier `cookie-domain` config), ordered oldest-first.
234+
`lws_jwt_auth_create()` walks every occurrence and uses the first that
235+
verifies and is unexpired, so a stale duplicate cannot shadow a live one;
236+
before that, a stale first cookie left the widget "Not logged in" until it
237+
aged out, since every renewal and login re-minted the *other* scope. To
238+
stop such stale duplicates accumulating, every `auth_session` cookie this
239+
plugin mints has its `Max-Age` capped at the JWT's own remaining `exp`, never
240+
just `jwt-validity-secs`. Only the status probe emits these lines, since it
241+
is the one request a not-logged-in widget always makes and scanners never do.

0 commit comments

Comments
 (0)