Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/token-source-require-exp.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'livekit-client': patch
---

Treat TokenSource JWTs without `exp` as expired, and still honor `exp` when `nbf` is absent
33 changes: 33 additions & 0 deletions src/room/token-source/utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown>) {
const encode = (value: Record<string, unknown>) =>
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(
Expand Down Expand Up @@ -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', () => {
Expand Down
17 changes: 12 additions & 5 deletions src/room/token-source/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
Loading