From 641136dcbdf13bfa499b3ae898d450b50596fb42 Mon Sep 17 00:00:00 2001 From: Sasha Mitchell Date: Wed, 19 Aug 2026 15:44:47 +0700 Subject: [PATCH 1/2] fix: treat TokenSource JWTs without exp as expired isResponseTokenValid returned true when nbf or exp was missing, so TokenSourceCached never refetched. nbf is optional; honor exp anyway. --- .changeset/token-source-require-exp.md | 5 ++++ src/room/token-source/utils.test.ts | 33 ++++++++++++++++++++++++++ src/room/token-source/utils.ts | 17 +++++++++---- 3 files changed, 50 insertions(+), 5 deletions(-) create mode 100644 .changeset/token-source-require-exp.md diff --git a/.changeset/token-source-require-exp.md b/.changeset/token-source-require-exp.md new file mode 100644 index 0000000000..913bbda376 --- /dev/null +++ b/.changeset/token-source-require-exp.md @@ -0,0 +1,5 @@ +--- +'livekit-client': patch +--- + +Treat TokenSource JWTs without `exp` as expired, and still honor `exp` when `nbf` is absent diff --git a/src/room/token-source/utils.test.ts b/src/room/token-source/utils.test.ts index 0bad227dd0..5ed6f8b857 100644 --- a/src/room/token-source/utils.test.ts +++ b/src/room/token-source/utils.test.ts @@ -3,6 +3,12 @@ import { describe, expect, it } from 'vitest'; import { TOKENS } from './test-tokens'; import { areTokenSourceFetchOptionsEqual, decodeTokenPayload, isResponseTokenValid } from './utils'; +function unsignedToken(payload: Record) { + const encode = (value: Record) => + btoa(JSON.stringify(value)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, ''); + return `${encode({ alg: 'none', typ: 'JWT' })}.${encode(payload)}.`; +} + describe('isResponseTokenValid', () => { it('should find a valid jwt not expired', () => { const isValid = isResponseTokenValid( @@ -31,6 +37,33 @@ describe('isResponseTokenValid', () => { ); expect(isValid).toBe(false); }); + it('should treat a jwt without exp as expired', () => { + const isValid = isResponseTokenValid( + TokenSourceResponse.fromJson({ + serverUrl: 'ws://localhost:7800', + participantToken: unsignedToken({ sub: '1234567890', nbf: 1234567890, iat: 1234567890 }), + }), + ); + expect(isValid).toBe(false); + }); + it('should honor exp when nbf is absent', () => { + const isValid = isResponseTokenValid( + TokenSourceResponse.fromJson({ + serverUrl: 'ws://localhost:7800', + participantToken: unsignedToken({ sub: '1234567890', exp: 1234567891, iat: 1234567890 }), + }), + ); + expect(isValid).toBe(false); + }); + it('should accept a non-expired jwt that omits nbf', () => { + const isValid = isResponseTokenValid( + TokenSourceResponse.fromJson({ + serverUrl: 'ws://localhost:7800', + participantToken: unsignedToken({ sub: '1234567890', exp: 9876543210, iat: 1234567890 }), + }), + ); + expect(isValid).toBe(true); + }); }); describe('decodeTokenPayload', () => { diff --git a/src/room/token-source/utils.ts b/src/room/token-source/utils.ts index be37087686..7416475107 100644 --- a/src/room/token-source/utils.ts +++ b/src/room/token-source/utils.ts @@ -7,19 +7,26 @@ const ONE_MINUTE_IN_MILLISECONDS = 60 * ONE_SECOND_IN_MILLISECONDS; export function isResponseTokenValid(response: TokenSourceResponse) { const jwtPayload = decodeTokenPayload(response.participantToken); - if (!jwtPayload?.nbf || !jwtPayload?.exp) { - return true; + // Missing exp: TokenSourceCached would otherwise return this response forever. + // nbf is optional (RFC 7519); do not skip the exp check when it is absent. + if (!jwtPayload?.exp) { + return false; } const now = new Date(); - const nbfInMilliseconds = jwtPayload.nbf * ONE_SECOND_IN_MILLISECONDS; - const nbfDate = new Date(nbfInMilliseconds); + if (jwtPayload.nbf) { + const nbfInMilliseconds = jwtPayload.nbf * ONE_SECOND_IN_MILLISECONDS; + const nbfDate = new Date(nbfInMilliseconds); + if (nbfDate > now) { + return false; + } + } const expInMilliseconds = jwtPayload.exp * ONE_SECOND_IN_MILLISECONDS; const expDate = new Date(expInMilliseconds - ONE_MINUTE_IN_MILLISECONDS); - return nbfDate <= now && expDate > now; + return expDate > now; } /** Given a LiveKit generated participant token, decodes and returns the associated {@link TokenPayload} data. */ From f90bd18b9763c0d18bc1b49bd9361a1e2ef78397 Mon Sep 17 00:00:00 2001 From: Ryan Gaus Date: Wed, 19 Aug 2026 09:47:04 -0400 Subject: [PATCH 2/2] refactor(token-source): move unsignedToken helper into test-tokens Colocate the unsigned JWT builder with the other generated test tokens and expose the three exp/nbf fixtures as TOKENS.NO_EXP, TOKENS.EXP_IN_PAST_NO_NBF, and TOKENS.VALID_NO_NBF. --- src/room/token-source/test-tokens.ts | 20 ++++++++++++++++++++ src/room/token-source/utils.test.ts | 12 +++--------- 2 files changed, 23 insertions(+), 9 deletions(-) diff --git a/src/room/token-source/test-tokens.ts b/src/room/token-source/test-tokens.ts index 5f8d991783..dc83de74f8 100644 --- a/src/room/token-source/test-tokens.ts +++ b/src/room/token-source/test-tokens.ts @@ -1,3 +1,11 @@ +// Builds an unsigned (`alg: none`) JWT. These aren't signed at all, so they can only be used in +// tests which don't care about the signature. +function unsignedToken(payload: Record) { + const encode = (value: Record) => + btoa(JSON.stringify(value)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, ''); + return `${encode({ alg: 'none', typ: 'JWT' })}.${encode(payload)}.`; +} + // Test JWTs created for test purposes only. // None of these actually auth against anything. export const TOKENS = { @@ -25,4 +33,16 @@ export const TOKENS = { // A dummy roomConfig value is also set, with room_config.name = "test room name", room_config.extraField = "extra field value", and room_config.agents = [{"agentName": "test agent name","metadata":"test agent metadata","extraField":"extra field value"}] EXTRA_FIELDS: 'eyJhbGciOiJFUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiIxMjM0NTY3ODkwIiwiZXhwIjo5ODc2NTQzMjEwLCJuYmYiOjEyMzQ1Njc4OTAsImlhdCI6MTIzNDU2Nzg5MCwicm9vbUNvbmZpZyI6eyJuYW1lIjoidGVzdCByb29tIG5hbWUiLCJlbXB0eVRpbWVvdXQiOjAsImRlcGFydHVyZVRpbWVvdXQiOjAsIm1heFBhcnRpY2lwYW50cyI6MCwibWluUGxheW91dERlbGF5IjowLCJtYXhQbGF5b3V0RGVsYXkiOjAsInN5bmNTdHJlYW1zIjpmYWxzZSwiYWdlbnRzIjpbeyJhZ2VudE5hbWUiOiJ0ZXN0IGFnZW50IG5hbWUiLCJtZXRhZGF0YSI6InRlc3QgYWdlbnQgbWV0YWRhdGEiLCJleHRyYUZpZWxkIjoiZXh0cmEgZmllbGQgdmFsdWUifV0sIm1ldGFkYXRhIjoiIiwiZXh0cmFGaWVsZCI6ImV4dHJhIGZpZWxkIHZhbHVlIn19Cg.EDetpHG8cSubaApzgWJaQrpCiSy9KDBlfCfVdIydbQ-_CHiNnXOK_f_mCJbTf9A-duT1jmvPOkLrkkWFT60XPQ', + + // Nbf date set at 1234567890 seconds (Fri Feb 13 2009 23:31:30 GMT+0000) + // No exp date set at all + NO_EXP: unsignedToken({ sub: '1234567890', nbf: 1234567890, iat: 1234567890 }), + + // No nbf date set at all + // Exp date set at 1234567891 seconds (Fri Feb 13 2009 23:31:31 GMT+0000) + EXP_IN_PAST_NO_NBF: unsignedToken({ sub: '1234567890', exp: 1234567891, iat: 1234567890 }), + + // No nbf date set at all + // Exp date set at 9876543210 seconds (Fri Dec 22 2282 20:13:30 GMT+0000) + VALID_NO_NBF: unsignedToken({ sub: '1234567890', exp: 9876543210, iat: 1234567890 }), }; diff --git a/src/room/token-source/utils.test.ts b/src/room/token-source/utils.test.ts index 5ed6f8b857..96884c3a06 100644 --- a/src/room/token-source/utils.test.ts +++ b/src/room/token-source/utils.test.ts @@ -3,12 +3,6 @@ import { describe, expect, it } from 'vitest'; import { TOKENS } from './test-tokens'; import { areTokenSourceFetchOptionsEqual, decodeTokenPayload, isResponseTokenValid } from './utils'; -function unsignedToken(payload: Record) { - const encode = (value: Record) => - btoa(JSON.stringify(value)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/g, ''); - return `${encode({ alg: 'none', typ: 'JWT' })}.${encode(payload)}.`; -} - describe('isResponseTokenValid', () => { it('should find a valid jwt not expired', () => { const isValid = isResponseTokenValid( @@ -41,7 +35,7 @@ describe('isResponseTokenValid', () => { const isValid = isResponseTokenValid( TokenSourceResponse.fromJson({ serverUrl: 'ws://localhost:7800', - participantToken: unsignedToken({ sub: '1234567890', nbf: 1234567890, iat: 1234567890 }), + participantToken: TOKENS.NO_EXP, }), ); expect(isValid).toBe(false); @@ -50,7 +44,7 @@ describe('isResponseTokenValid', () => { const isValid = isResponseTokenValid( TokenSourceResponse.fromJson({ serverUrl: 'ws://localhost:7800', - participantToken: unsignedToken({ sub: '1234567890', exp: 1234567891, iat: 1234567890 }), + participantToken: TOKENS.EXP_IN_PAST_NO_NBF, }), ); expect(isValid).toBe(false); @@ -59,7 +53,7 @@ describe('isResponseTokenValid', () => { const isValid = isResponseTokenValid( TokenSourceResponse.fromJson({ serverUrl: 'ws://localhost:7800', - participantToken: unsignedToken({ sub: '1234567890', exp: 9876543210, iat: 1234567890 }), + participantToken: TOKENS.VALID_NO_NBF, }), ); expect(isValid).toBe(true);