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/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 0bad227dd0..96884c3a06 100644 --- a/src/room/token-source/utils.test.ts +++ b/src/room/token-source/utils.test.ts @@ -31,6 +31,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: TOKENS.NO_EXP, + }), + ); + expect(isValid).toBe(false); + }); + it('should honor exp when nbf is absent', () => { + const isValid = isResponseTokenValid( + TokenSourceResponse.fromJson({ + serverUrl: 'ws://localhost:7800', + participantToken: TOKENS.EXP_IN_PAST_NO_NBF, + }), + ); + expect(isValid).toBe(false); + }); + it('should accept a non-expired jwt that omits nbf', () => { + const isValid = isResponseTokenValid( + TokenSourceResponse.fromJson({ + serverUrl: 'ws://localhost:7800', + participantToken: TOKENS.VALID_NO_NBF, + }), + ); + 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. */