diff --git a/.changeset/mean-mirrors-change.md b/.changeset/mean-mirrors-change.md new file mode 100644 index 0000000000..952f63b6fb --- /dev/null +++ b/.changeset/mean-mirrors-change.md @@ -0,0 +1,5 @@ +--- +'livekit-client': patch +--- + +add TokenSource token fetching abstraction diff --git a/package.json b/package.json index 75c3f6aa62..1e35ff3918 100644 --- a/package.json +++ b/package.json @@ -56,8 +56,9 @@ }, "dependencies": { "@livekit/mutex": "1.1.1", - "@livekit/protocol": "1.41.0", + "@livekit/protocol": "1.42.0", "events": "^3.3.0", + "jose": "^6.1.0", "loglevel": "^1.9.2", "sdp-transform": "^2.15.0", "ts-debounce": "^4.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index aed8df5461..49b9c4e561 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,14 +12,17 @@ importers: specifier: 1.1.1 version: 1.1.1 '@livekit/protocol': - specifier: 1.41.0 - version: 1.41.0 + specifier: 1.42.0 + version: 1.42.0 '@types/dom-mediacapture-record': specifier: ^1 version: 1.0.22 events: specifier: ^3.3.0 version: 3.3.0 + jose: + specifier: ^6.1.0 + version: 6.1.0 loglevel: specifier: ^1.9.2 version: 1.9.2 @@ -1039,8 +1042,8 @@ packages: '@livekit/mutex@1.1.1': resolution: {integrity: sha512-EsshAucklmpuUAfkABPxJNhzj9v2sG7JuzFDL4ML1oJQSV14sqrpTYnsaOudMAw9yOaW53NU3QQTlUQoRs4czw==} - '@livekit/protocol@1.41.0': - resolution: {integrity: sha512-bozBB39VSbd0IjRBwShMlLskqkd9weWJNskaB1CVpcEO9UUI1gMwAtBJOKYblzZJT9kE1SJa3L4oWWwsZMzSXw==} + '@livekit/protocol@1.42.0': + resolution: {integrity: sha512-42sYSCay2PZrn5yHHt+O3RQpTElcTrA7bqg7iYbflUApeerA5tUCJDr8Z4abHsYHVKjqVUbkBq/TPmT3X6aYOQ==} '@manypkg/find-root@1.1.0': resolution: {integrity: sha512-mki5uBvhHzO8kYYix/WRy2WX8S3B5wdVSc9D6KcU5lQNglP2yt58/VfLuAK49glRXChosY8ap2oJ1qgma3GUVA==} @@ -2578,6 +2581,9 @@ packages: resolution: {integrity: sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==} hasBin: true + jose@6.1.0: + resolution: {integrity: sha512-TTQJyoEoKcC1lscpVDCSsVgYzUDg/0Bt3WE//WiTPK6uOCQC2KZS4MpugbMWt/zyjkopgZoXhZuCi00gLudfUA==} + js-tokens@4.0.0: resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} @@ -3447,8 +3453,8 @@ packages: engines: {node: '>=14.17'} hasBin: true - typescript@6.0.0-dev.20250916: - resolution: {integrity: sha512-Xfuwn53pwofpt/hGVqLcqkqEN9u7jWpPAagOj2QgGmgOZH4P8pzPpwBB4piRkOqYdmiSg6qOP4xK3L9iNRTF6A==} + typescript@6.0.0-dev.20250923: + resolution: {integrity: sha512-Aiy0yklpKnRJkElhXO/bB2DhosYfL+j4RiUinq6C58ncyDOjuKn5wk6O5rds4n4AOobAqMjLU55H5q7bEyDcMQ==} engines: {node: '>=14.17'} hasBin: true @@ -4818,7 +4824,7 @@ snapshots: '@livekit/mutex@1.1.1': {} - '@livekit/protocol@1.41.0': + '@livekit/protocol@1.42.0': dependencies: '@bufbuild/protobuf': 1.10.1 @@ -5681,7 +5687,7 @@ snapshots: dependencies: semver: 7.6.0 shelljs: 0.8.5 - typescript: 6.0.0-dev.20250916 + typescript: 6.0.0-dev.20250923 dunder-proto@1.0.1: dependencies: @@ -6605,6 +6611,8 @@ snapshots: jiti@2.4.2: {} + jose@6.1.0: {} + js-tokens@4.0.0: {} js-tokens@9.0.1: {} @@ -7522,7 +7530,7 @@ snapshots: typescript@5.8.3: {} - typescript@6.0.0-dev.20250916: {} + typescript@6.0.0-dev.20250923: {} uc.micro@2.1.0: {} diff --git a/src/index.ts b/src/index.ts index d070c12f14..84039eacc6 100644 --- a/src/index.ts +++ b/src/index.ts @@ -66,6 +66,8 @@ export * from './room/errors'; export * from './room/events'; export * from './room/track/Track'; export * from './room/track/create'; +export * from './room/token-source/TokenSource'; +export * from './room/token-source/types'; export { facingModeFromDeviceLabel, facingModeFromLocalTrack } from './room/track/facingMode'; export * from './room/track/options'; export * from './room/track/processor/types'; diff --git a/src/logger.ts b/src/logger.ts index b0a8ab3e06..23623bfb80 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -12,6 +12,7 @@ export enum LogLevel { export enum LoggerNames { Default = 'livekit', Room = 'livekit-room', + TokenSource = 'livekit-token-source', Participant = 'livekit-participant', Track = 'livekit-track', Publication = 'livekit-track-publication', diff --git a/src/room/token-source/TokenSource.ts b/src/room/token-source/TokenSource.ts new file mode 100644 index 0000000000..5f5cfdeefb --- /dev/null +++ b/src/room/token-source/TokenSource.ts @@ -0,0 +1,285 @@ +import { Mutex } from '@livekit/mutex'; +import { + RoomAgentDispatch, + RoomConfiguration, + TokenSourceRequest, + TokenSourceResponse, +} from '@livekit/protocol'; +import { + TokenSourceConfigurable, + type TokenSourceFetchOptions, + TokenSourceFixed, + type TokenSourceResponseObject, +} from './types'; +import { decodeTokenPayload, isResponseExpired } from './utils'; + +/** A TokenSourceCached is a TokenSource which caches the last {@link TokenSourceResponseObject} value and returns it + * until a) it expires or b) the {@link TokenSourceFetchOptions} provided to .fetch(...) change. */ +abstract class TokenSourceCached extends TokenSourceConfigurable { + private cachedFetchOptions: TokenSourceFetchOptions | null = null; + + private cachedResponse: TokenSourceResponse | null = null; + + private fetchMutex = new Mutex(); + + private isSameAsCachedFetchOptions(options: TokenSourceFetchOptions) { + if (!this.cachedFetchOptions) { + return false; + } + + for (const key of Object.keys(this.cachedFetchOptions) as Array< + keyof TokenSourceFetchOptions + >) { + switch (key) { + case 'roomName': + case 'participantName': + case 'participantIdentity': + case 'participantMetadata': + case 'participantAttributes': + case 'agentName': + case 'agentMetadata': + if (this.cachedFetchOptions[key] !== options[key]) { + return false; + } + break; + default: + // ref: https://stackoverflow.com/a/58009992 + const exhaustiveCheckedKey: never = key; + throw new Error(`Options key ${exhaustiveCheckedKey} not being checked for equality!`); + } + } + + return true; + } + + private shouldReturnCachedValueFromFetch(fetchOptions: TokenSourceFetchOptions) { + if (!this.cachedResponse) { + return false; + } + if (isResponseExpired(this.cachedResponse)) { + return false; + } + if (this.isSameAsCachedFetchOptions(fetchOptions)) { + return false; + } + return true; + } + + getCachedResponseJwtPayload() { + if (!this.cachedResponse) { + return null; + } + return decodeTokenPayload(this.cachedResponse.participantToken); + } + + async fetch(options: TokenSourceFetchOptions): Promise { + const unlock = await this.fetchMutex.lock(); + try { + if (this.shouldReturnCachedValueFromFetch(options)) { + return this.cachedResponse!.toJson() as TokenSourceResponseObject; + } + this.cachedFetchOptions = options; + + const tokenResponse = await this.update(options); + this.cachedResponse = tokenResponse; + return tokenResponse.toJson() as TokenSourceResponseObject; + } finally { + unlock(); + } + } + + protected abstract update(options: TokenSourceFetchOptions): Promise; +} + +type LiteralOrFn = + | TokenSourceResponseObject + | (() => TokenSourceResponseObject | Promise); +export class TokenSourceLiteral extends TokenSourceFixed { + private literalOrFn: LiteralOrFn; + + constructor(literalOrFn: LiteralOrFn) { + super(); + this.literalOrFn = literalOrFn; + } + + async fetch(): Promise { + if (typeof this.literalOrFn === 'function') { + return this.literalOrFn(); + } else { + return this.literalOrFn; + } + } +} + +type CustomFn = ( + options: TokenSourceFetchOptions, +) => TokenSourceResponseObject | Promise; +export class TokenSourceCustom extends TokenSourceCached { + private customFn: CustomFn; + + constructor(customFn: CustomFn) { + super(); + this.customFn = customFn; + } + + protected async update(options: TokenSourceFetchOptions) { + const resultMaybePromise = this.customFn(options); + + let result; + if (resultMaybePromise instanceof Promise) { + result = await resultMaybePromise; + } else { + result = resultMaybePromise; + } + + return TokenSourceResponse.fromJson(result, { + // NOTE: it could be possible that the response body could contain more fields than just + // what's in TokenSourceResponse depending on the implementation + ignoreUnknownFields: true, + }); + } +} + +export type EndpointOptions = Omit; + +export class TokenSourceEndpoint extends TokenSourceCached { + private url: string; + + private endpointOptions: EndpointOptions; + + constructor(url: string, options: EndpointOptions = {}) { + super(); + this.url = url; + this.endpointOptions = options; + } + + private createRequestFromOptions(options: TokenSourceFetchOptions) { + const request = new TokenSourceRequest(); + + for (const key of Object.keys(options) as Array) { + switch (key) { + case 'roomName': + case 'participantName': + case 'participantIdentity': + case 'participantMetadata': + request[key] = options[key]; + break; + + case 'participantAttributes': + request.participantAttributes = options.participantAttributes ?? {}; + break; + + case 'agentName': + request.roomConfig = request.roomConfig ?? new RoomConfiguration(); + if (request.roomConfig.agents.length === 0) { + request.roomConfig.agents.push(new RoomAgentDispatch()); + } + request.roomConfig.agents[0].agentName = options.agentName!; + break; + + case 'agentMetadata': + request.roomConfig = request.roomConfig ?? new RoomConfiguration(); + if (request.roomConfig.agents.length === 0) { + request.roomConfig.agents.push(new RoomAgentDispatch()); + } + request.roomConfig.agents[0].metadata = options.agentMetadata!; + break; + + default: + // ref: https://stackoverflow.com/a/58009992 + const exhaustiveCheckedKey: never = key; + throw new Error( + `Options key ${exhaustiveCheckedKey} not being included in forming request!`, + ); + } + } + + return request; + } + + protected async update(options: TokenSourceFetchOptions) { + const request = this.createRequestFromOptions(options); + + const response = await fetch(this.url, { + ...this.endpointOptions, + method: this.endpointOptions.method ?? 'POST', + headers: { + 'Content-Type': 'application/json', + ...this.endpointOptions.headers, + }, + body: request.toJsonString({ + useProtoFieldName: true, + }), + }); + + if (!response.ok) { + throw new Error( + `Error generating token from endpoint ${this.url}: received ${response.status} / ${await response.text()}`, + ); + } + + const body = await response.json(); + return TokenSourceResponse.fromJson(body, { + // NOTE: it could be possible that the response body could contain more fields than just + // what's in TokenSourceResponse depending on the implementation (ie, SandboxTokenServer) + ignoreUnknownFields: true, + }); + } +} + +export type SandboxTokenServerOptions = { + baseUrl?: string; +}; + +export class TokenSourceSandboxTokenServer extends TokenSourceEndpoint { + constructor(sandboxId: string, options: SandboxTokenServerOptions) { + const { baseUrl = 'https://cloud-api.livekit.io', ...rest } = options; + + super(`${baseUrl}/api/v2/sandbox/connection-details`, { + ...rest, + headers: { + 'X-Sandbox-ID': sandboxId, + }, + }); + } +} + +export const TokenSource = { + /** TokenSource.literal contains a single, literal set of {@link TokenSourceResponseObject} + * credentials, either provided directly or returned from a provided function. */ + literal(literalOrFn: LiteralOrFn) { + return new TokenSourceLiteral(literalOrFn); + }, + + /** + * TokenSource.custom allows a user to define a manual function which generates new + * {@link TokenSourceResponseObject} values on demand. + * + * Use this to get credentials from custom backends / etc. + */ + custom(customFn: CustomFn) { + return new TokenSourceCustom(customFn); + }, + + /** + * TokenSource.endpoint creates a token source that fetches credentials from a given URL using + * the standard endpoint format: + * FIXME: add docs link here in the future! + */ + endpoint(url: string, options: EndpointOptions = {}) { + return new TokenSourceEndpoint(url, options); + }, + + /** + * TokenSource.sandboxTokenServer queries a sandbox token server for credentials, + * which supports quick prototyping / getting started types of use cases. + * + * This token provider is INSECURE and should NOT be used in production. + * + * For more info: + * @see https://cloud.livekit.io/projects/p_/sandbox/templates/token-server + */ + sandboxTokenServer(sandboxId: string, options: SandboxTokenServerOptions = {}) { + return new TokenSourceSandboxTokenServer(sandboxId, options); + }, +}; diff --git a/src/room/token-source/types.ts b/src/room/token-source/types.ts new file mode 100644 index 0000000000..37bd0c813a --- /dev/null +++ b/src/room/token-source/types.ts @@ -0,0 +1,84 @@ +import { RoomConfiguration, TokenSourceRequest, TokenSourceResponse } from '@livekit/protocol'; +import type { JWTPayload } from 'jose'; +import type { ValueToSnakeCase } from '../../utils/camelToSnakeCase'; +// The below imports are being linked in tsdoc comments, so they have to be imported even if they +// aren't being used. +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import type { TokenSourceCustom, TokenSourceEndpoint, TokenSourceLiteral } from './TokenSource'; + +export type TokenSourceRequestObject = Required< + NonNullable[0]> +>; +export type TokenSourceResponseObject = Required< + NonNullable[0]> +>; + +/** The `TokenSource` request object sent to the server as part of fetching a configurable + * `TokenSource` like {@link TokenSourceEndpoint}. + * + * Use this as a type for your request body if implementing a server endpoint in node.js. + */ +export type TokenSourceRequestPayload = ValueToSnakeCase; + +/** The `TokenSource` response object sent from the server as part of fetching a configurable + * `TokenSource` like {@link TokenSourceEndpoint}. + * + * Use this as a type for your response body if implementing a server endpoint in node.js. + */ +export type TokenSourceResponsePayload = ValueToSnakeCase; + +/** The payload of a LiveKit JWT token. */ +export type TokenPayload = JWTPayload & { + name?: string; + metadata?: string; + attributes?: Record; + video?: { + room?: string; + roomJoin?: boolean; + canPublish?: boolean; + canPublishData?: boolean; + canSubscribe?: boolean; + }; + roomConfig?: RoomConfigurationObject; +}; +export type RoomConfigurationObject = NonNullable< + ConstructorParameters[0] +>; + +/** A Fixed TokenSource is a token source that takes no parameters and returns a completely + * independently derived value on each fetch() call. + * + * The most common downstream implementer is {@link TokenSourceLiteral}. + */ +export abstract class TokenSourceFixed { + abstract fetch(): Promise; +} + +export type TokenSourceFetchOptions = { + roomName?: string; + participantName?: string; + participantIdentity?: string; + participantMetadata?: string; + participantAttributes?: { [key: string]: string }; + + agentName?: string; + agentMetadata?: string; +}; + +/** A Configurable TokenSource is a token source that takes a + * {@link TokenSourceFetchOptions} object as input and returns a deterministic + * {@link TokenSourceResponseObject} output based on the options specified. + * + * For example, if options.participantName is set, it should be expected that + * all tokens that are generated will have participant name field set to the + * provided value. + * + * A few common downstream implementers are {@link TokenSourceEndpoint} + * and {@link TokenSourceCustom}. + */ +export abstract class TokenSourceConfigurable { + abstract fetch(options: TokenSourceFetchOptions): Promise; +} + +/** A TokenSource is a mechanism for fetching credentials required to connect to a LiveKit Room. */ +export type TokenSourceBase = TokenSourceFixed | TokenSourceConfigurable; diff --git a/src/room/token-source/utils.ts b/src/room/token-source/utils.ts new file mode 100644 index 0000000000..f6d328b2bd --- /dev/null +++ b/src/room/token-source/utils.ts @@ -0,0 +1,35 @@ +import { RoomConfiguration, type TokenSourceResponse } from '@livekit/protocol'; +import { decodeJwt } from 'jose'; +import type { RoomConfigurationObject, TokenPayload } from './types'; + +const ONE_SECOND_IN_MILLISECONDS = 1000; +const ONE_MINUTE_IN_MILLISECONDS = 60 * ONE_SECOND_IN_MILLISECONDS; + +export function isResponseExpired(response: TokenSourceResponse) { + const jwtPayload = decodeTokenPayload(response.participantToken); + if (!jwtPayload?.exp) { + return true; + } + const expInMilliseconds = jwtPayload.exp * ONE_SECOND_IN_MILLISECONDS; + const expiresAt = new Date(expInMilliseconds - ONE_MINUTE_IN_MILLISECONDS); + + const now = new Date(); + return expiresAt >= now; +} + +export function decodeTokenPayload(token: string) { + const payload = decodeJwt>(token); + + const { roomConfig, ...rest } = payload; + + const mappedPayload: TokenPayload = { + ...rest, + roomConfig: payload.roomConfig + ? (RoomConfiguration.fromJson( + payload.roomConfig as Record, + ) as RoomConfigurationObject) + : undefined, + }; + + return mappedPayload; +} diff --git a/src/utils/camelToSnakeCase.ts b/src/utils/camelToSnakeCase.ts new file mode 100644 index 0000000000..c59dfbea78 --- /dev/null +++ b/src/utils/camelToSnakeCase.ts @@ -0,0 +1,16 @@ +export type CamelToSnakeCase = Str extends `${infer First}${infer Rest}` + ? `${First extends Capitalize ? '_' : ''}${Lowercase}${CamelToSnakeCase}` + : Str; + +type ArrayValuesToSnakeCase = Array>; + +type ObjectKeysToSnakeCase = { + [Key in keyof Obj as CamelToSnakeCase]: NonNullable>; +}; + +export type ValueToSnakeCase = + Value extends Array + ? ArrayValuesToSnakeCase + : Value extends object + ? ObjectKeysToSnakeCase + : Value;