2023-07-05 13:12:37 +01:00
/*
2024-09-06 10:22:13 +02:00
Copyright 2023, 2024 New Vector Ltd.
2023-07-05 13:12:37 +01:00
2025-02-18 17:59:58 +00:00
SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
2024-09-06 10:22:13 +02:00
Please see LICENSE in the repository root for full details.
2023-07-05 13:12:37 +01:00
*/
2026-05-06 09:18:27 +02:00
import {
type IOpenIDToken ,
type MatrixClient ,
2026-09-19 13:42:22 -04:00
MatrixError ,
2026-05-06 09:18:27 +02:00
parseErrorResponse ,
} from "matrix-js-sdk" ;
2025-12-17 09:53:49 +01:00
import { type CallMembershipIdentityParts } from "matrix-js-sdk/lib/matrixrtc/EncryptionManager" ;
2025-12-29 17:38:54 +01:00
import { type Logger } from "matrix-js-sdk/lib/logger" ;
2023-07-05 13:12:37 +01:00
2026-01-09 13:38:26 +01:00
import {
FailToGetOpenIdToken ,
NoMatrix2AuthorizationService ,
2026-09-19 13:42:22 -04:00
SFUTokenRefusedError ,
2026-01-09 13:38:26 +01:00
} from "../utils/errors" ;
2025-03-21 15:07:15 -04:00
import { doNetworkOperationWithRetry } from "../utils/matrix" ;
2025-12-17 09:53:49 +01:00
import { Config } from "../config/Config" ;
2026-01-09 13:38:26 +01:00
import { JwtEndpointVersion } from "../state/CallViewModel/localMember/LocalTransport" ;
2023-07-12 17:57:54 +01:00
2025-12-29 17:45:41 +00:00
/**
* Configuration and access tokens provided by the SFU on successful authentication.
*/
2023-07-05 13:12:37 +01:00
export interface SFUConfig {
url : string ;
jwt : string ;
2025-12-29 17:45:41 +00:00
livekitAlias : string ;
2026-01-05 21:58:26 +01:00
// NOTE: Currently unused.
2025-12-29 17:45:41 +00:00
livekitIdentity : string ;
}
/**
* Decoded details from the JWT.
*/
interface SFUJWTPayload {
/**
* Expiration time for the JWT.
* Note: This value is in seconds since Unix epoch.
*/
exp : number ;
/**
* Name of the instance which authored the JWT
*/
iss : string ;
/**
* Time at which the JWT can start to be used.
* Note: This value is in seconds since Unix epoch.
*/
nbf : number ;
/**
* Subject. The Livekit alias in this context.
*/
sub : string ;
/**
* The set of permissions for the user.
*/
video : {
canPublish : boolean ;
canSubscribe : boolean ;
room : string ;
roomJoin : boolean ;
};
2023-07-05 13:12:37 +01:00
}
// The bits we need from MatrixClient
export type OpenIDClientParts = Pick <
MatrixClient ,
"getOpenIdToken" | "getDeviceId"
> ;
2026-04-02 14:38:49 +02:00
2025-11-20 14:42:12 +01:00
/**
2025-11-21 13:04:28 +01:00
* Gets a bearer token from the homeserver and then use it to authenticate
* to the matrix RTC backend in order to get acces to the SFU.
* It has built-in retry for calls to the homeserver with a backoff policy.
2025-12-30 17:02:44 +01:00
* @param client The Matrix client
2026-01-05 22:20:19 +01:00
* @param membership Our own membership identity parts used to send to jwt service.
2025-12-30 17:02:44 +01:00
* @param serviceUrl The URL of the livekit SFU service
2026-01-09 13:38:26 +01:00
* @param roomId The room id used in the jwt request. This is NOT the livekit_alias. The jwt service will provide the alias. It maps matrix room ids <-> Livekit aliases.
2026-01-28 14:22:21 +01:00
* @param opts Additional options to modify which endpoint with which data will be used to acquire the jwt token.
* @param opts.forceJwtEndpoint This will use the old jwt endpoint which will create the rtc backend identity based on string concatenation
2025-12-29 17:38:54 +01:00
* instead of a hash.
* This function by default uses whatever is possible with the current jwt service installed next to the SFU.
* For remote connections this does not matter, since we will not publish there we can rely on the newest option.
* For our own connection we can only use the hashed version if we also send the new matrix2.0 sticky events.
2026-01-09 13:38:26 +01:00
* @param opts.delayEndpointBaseUrl The URL of the matrix homeserver.
* @param opts.delayId The delay id used for the jwt service to manage.
2026-01-05 22:20:19 +01:00
* @param logger optional logger.
2025-11-21 13:04:28 +01:00
* @returns Object containing the token information
2025-11-20 14:42:12 +01:00
* @throws FailToGetOpenIdToken
*/
2023-07-05 13:12:37 +01:00
export async function getSFUConfigWithOpenID (
client : OpenIDClientParts ,
2025-12-17 09:53:49 +01:00
membership : CallMembershipIdentityParts ,
2025-08-27 14:01:01 +02:00
serviceUrl : string ,
2026-01-05 21:08:21 +01:00
roomId : string ,
2026-01-09 13:38:26 +01:00
opts ?: {
forceJwtEndpoint? : JwtEndpointVersion ;
delayEndpointBaseUrl? : string ;
delayId? : string ;
},
2025-12-29 17:38:54 +01:00
logger? : Logger ,
2025-08-27 14:01:01 +02:00
) : Promise < SFUConfig > {
2025-03-11 09:07:19 +01:00
let openIdToken : IOpenIDToken ;
try {
openIdToken = await doNetworkOperationWithRetry ( async () =>
client . getOpenIdToken (),
);
} catch ( error ) {
throw new FailToGetOpenIdToken (
error instanceof Error ? error : new Error ( "Unknown error" ),
);
}
2025-12-29 17:38:54 +01:00
logger ? . debug ( "Got openID token" , openIdToken );
2026-01-07 15:36:32 +01:00
let sfuConfig : { url : string ; jwt : string } | undefined ;
2025-12-29 17:38:54 +01:00
2026-01-09 18:05:26 +01:00
const tryBothJwtEndpoints = opts ? . forceJwtEndpoint === undefined ; // This is for SFUs where we do not publish.
const forceMatrix2Jwt =
opts ? . forceJwtEndpoint === JwtEndpointVersion . Matrix_2_0 ;
// We want to start using the new endpoint (with optional delay delegation)
// if we can use both or if we are forced to use the new one.
if ( tryBothJwtEndpoints || forceMatrix2Jwt ) {
2026-01-07 15:36:32 +01:00
try {
2026-04-02 14:38:49 +02:00
logger ? . info (
`Trying to get JWT with delegation for focus ${ serviceUrl } ...` ,
);
const sfuConfig = await getLiveKitJWTWithDelayDelegation (
2026-01-07 17:38:29 +01:00
membership ,
serviceUrl ,
roomId ,
openIdToken ,
2026-01-09 13:38:26 +01:00
opts ? . delayEndpointBaseUrl ,
opts ? . delayId ,
2026-01-07 17:38:29 +01:00
);
2026-04-02 14:38:49 +02:00
return extractFullConfigFromToken ( sfuConfig );
2026-01-07 15:36:32 +01:00
} catch ( e ) {
2026-03-31 11:38:21 +02:00
logger ? . debug ( `Failed fetching jwt with matrix 2.0 endpoint:` , e );
2026-04-02 14:38:49 +02:00
// Make this throw a hard error in case we force the matrix2.0 endpoint.
2026-04-02 20:42:09 +02:00
if ( forceMatrix2Jwt ) {
throw new NoMatrix2AuthorizationService ( e as Error );
}
2026-01-07 15:36:32 +01:00
}
}
2026-01-07 17:21:08 +01:00
// DEPRECATED
2026-04-02 14:38:49 +02:00
// here we either have a sfuConfig or we already exited because of `if (forceMatrix2) throw ...`
2026-01-09 18:05:26 +01:00
// The only case we can get into this condition is, if `forceMatrix2` is `false`
2026-04-02 14:38:49 +02:00
try {
logger ? . info (
`Trying to get JWT with legacy endpoint for focus ${ serviceUrl } ...` ,
);
2026-01-07 17:38:29 +01:00
sfuConfig = await getLiveKitJWT (
membership . deviceId ,
serviceUrl ,
roomId ,
openIdToken ,
2026-05-05 17:33:39 +02:00
opts ? . delayEndpointBaseUrl ,
opts ? . delayId ,
2026-01-07 17:38:29 +01:00
);
2026-01-07 17:21:08 +01:00
logger ? . info ( `Got JWT from call's active focus URL.` );
2026-04-02 14:38:49 +02:00
return extractFullConfigFromToken ( sfuConfig );
} catch ( ex ) {
2026-09-19 13:42:22 -04:00
// [lotus] A 403 from the token service carries the reason the user needs
// ("This voice channel is full.") — surface it instead of the generic error.
if ( ex instanceof MatrixError && ex . httpStatus === 403 ) {
const reason = ( ex . data as { error? : unknown } | undefined ) ? . error ;
if ( typeof reason === "string" && reason . trim ()) {
throw new SFUTokenRefusedError ( reason , ex );
}
}
2026-04-02 14:38:49 +02:00
throw new FailToGetOpenIdToken (
ex instanceof Error ? ex : new Error ( `Unknown error ${ ex } ` ),
);
2026-01-07 17:21:08 +01:00
}
2026-04-02 14:38:49 +02:00
}
2026-01-07 17:21:08 +01:00
2026-04-02 14:38:49 +02:00
function extractFullConfigFromToken ( sfuConfig : {
url : string ;
jwt : string ;
}) : SFUConfig {
2025-12-29 17:45:41 +00:00
const [, payloadStr ] = sfuConfig . jwt . split ( "." );
const payload = JSON . parse ( global . atob ( payloadStr )) as SFUJWTPayload ;
return {
jwt : sfuConfig.jwt ,
url : sfuConfig.url ,
livekitAlias : payload.video.room ,
// NOTE: Currently unused.
2026-01-05 21:58:26 +01:00
// Probably also not helpful since we now compute the backendIdentity on joining the call so we can use it for the encryption manager.
// The only reason for us to know it locally is to connect the right users with the lk world. (and to set our own keys)
2025-12-29 17:45:41 +00:00
livekitIdentity : payload.sub ,
};
2023-07-12 17:57:54 +01:00
}
2026-04-02 14:38:49 +02:00
2023-07-12 17:57:54 +01:00
async function getLiveKitJWT (
2026-01-05 21:58:26 +01:00
deviceId : string ,
2023-07-12 17:57:54 +01:00
livekitServiceURL : string ,
2026-01-05 21:08:21 +01:00
matrixRoomId : string ,
2023-10-11 10:42:04 -04:00
openIDToken : IOpenIDToken ,
2026-05-05 17:33:39 +02:00
delayEndpointBaseUrl? : string ,
delayId? : string ,
2025-12-29 17:45:41 +00:00
) : Promise < { url : string ; jwt : string } > {
2026-05-05 17:33:39 +02:00
interface IDelayParams {
delay_id? : string ;
delay_timeout? : number ;
delay_cs_api_url? : string ;
}
let bodyDalayParts : IDelayParams = {};
// Also check for empty string
if ( delayId && delayEndpointBaseUrl ) {
const delayTimeoutMs =
Config . get (). matrix_rtc_session ? . delayed_leave_event_delay_ms ;
bodyDalayParts = {
delay_id : delayId ,
delay_timeout : delayTimeoutMs ,
delay_cs_api_url : delayEndpointBaseUrl ,
};
}
const makeRequest = async ( delayParts : IDelayParams ) : Promise < Response > => {
2026-04-02 14:38:49 +02:00
return await fetch ( livekitServiceURL + "/sfu/get" , {
2026-01-07 17:38:29 +01:00
method : "POST" ,
headers : {
"Content-Type" : "application/json" ,
},
body : JSON.stringify ({
2026-05-05 17:33:39 +02:00
// The legacy JWT endpoint uses only the matrix room id to calculate the livekit room alias.
// However, the livekit room alias is provided as part of the JWT payload.
2026-01-07 17:38:29 +01:00
room : matrixRoomId ,
openid_token : openIDToken ,
device_id : deviceId ,
2026-05-05 17:33:39 +02:00
... delayParts ,
2026-01-07 17:38:29 +01:00
}),
});
2026-05-05 17:33:39 +02:00
};
const res = await doNetworkOperationWithRetry ( async () => {
let response = await makeRequest ( bodyDalayParts );
// Old service compatibility check
const oldServiceDoesNotSupportDelayParts =
response . status === 400 && Object . keys ( bodyDalayParts ). length > 0 ;
// If http status 400 with M_BAD_JSON and we sent delay parts, retry without them
if ( oldServiceDoesNotSupportDelayParts ) {
try {
const errorBody = await response . json ();
if ( errorBody . errcode === "M_BAD_JSON" ) {
response = await makeRequest ({});
}
} catch {
// If we can't parse the error, treat as real error
}
}
return response ;
2026-01-07 17:21:08 +01:00
});
2026-04-02 14:38:49 +02:00
2026-01-07 17:21:08 +01:00
if ( ! res . ok ) {
2026-05-06 09:18:27 +02:00
throw parseErrorResponse ( res , await res . text ());
2026-01-07 17:21:08 +01:00
}
return await res . json ();
}
class NotSupportedError extends Error {
public constructor ( message : string ) {
super ( message );
this . name = "NotSupported" ;
2023-07-05 13:12:37 +01:00
}
}
2025-12-17 09:53:49 +01:00
export async function getLiveKitJWTWithDelayDelegation (
membership : CallMembershipIdentityParts ,
livekitServiceURL : string ,
2026-01-05 21:08:21 +01:00
matrixRoomId : string ,
2025-12-17 09:53:49 +01:00
openIDToken : IOpenIDToken ,
delayEndpointBaseUrl? : string ,
delayId? : string ,
2026-01-05 21:08:21 +01:00
) : Promise < { url : string ; jwt : string } > {
2025-12-17 09:53:49 +01:00
const { userId , deviceId , memberId } = membership ;
const body = {
2026-01-05 21:08:21 +01:00
room_id : matrixRoomId ,
2025-12-17 09:53:49 +01:00
slot_id : "m.call#ROOM" ,
openid_token : openIDToken ,
member : {
id : memberId ,
claimed_user_id : userId ,
claimed_device_id : deviceId ,
},
};
let bodyDalayParts = {};
// Also check for empty string
2026-01-09 13:38:26 +01:00
if ( delayId && delayEndpointBaseUrl ) {
2025-12-17 09:53:49 +01:00
const delayTimeoutMs =
2026-05-05 17:33:39 +02:00
Config . get (). matrix_rtc_session ? . delayed_leave_event_delay_ms ;
2025-12-17 09:53:49 +01:00
bodyDalayParts = {
delay_id : delayId ,
delay_timeout : delayTimeoutMs ,
delay_cs_api_url : delayEndpointBaseUrl ,
};
}
2026-04-02 14:38:49 +02:00
const res = await doNetworkOperationWithRetry ( async () => {
return await fetch ( livekitServiceURL + "/get_token" , {
2026-01-07 17:38:29 +01:00
method : "POST" ,
headers : {
"Content-Type" : "application/json" ,
},
body : JSON.stringify ({ ... body , ... bodyDalayParts }),
});
2026-01-07 17:21:08 +01:00
});
2026-01-07 17:38:29 +01:00
2026-01-07 17:21:08 +01:00
if ( ! res . ok ) {
const msg = "SFU Config fetch failed with status code " + res . status ;
if ( res . status === 404 ) {
throw new NotSupportedError ( msg );
} else {
2026-05-06 09:18:27 +02:00
throw parseErrorResponse ( res , await res . text ());
2025-12-17 09:53:49 +01:00
}
}
2026-01-07 17:21:08 +01:00
return await res . json ();
2025-12-17 09:53:49 +01:00
}