Skip to main content

o_sfu/runtime/
auth.rs

1use std::{
2    collections::BTreeMap,
3    time::{Duration, SystemTime, UNIX_EPOCH},
4};
5
6use base64::{
7    Engine as _,
8    engine::general_purpose::{STANDARD, URL_SAFE},
9};
10use hmac::{Hmac, KeyInit, Mac};
11use o_sfu_protocol::wire::{UserId, UserPermissions};
12use o_sfu_rfc::jwt::{ALGORITHM_HS256, JwtHeader, TYPE_JWT, URL_SAFE_NO_PAD};
13pub use o_sfu_rfc::jwt::{NumericDate, RegisteredJwtClaims};
14use serde::{Deserialize, Serialize, de::DeserializeOwned};
15use sha2::Sha256;
16use thiserror::Error;
17
18type HmacSha256 = Hmac<Sha256>;
19
20/// Proves [`verify`] accepted a JWT under the supplied key.
21pub(super) struct AuthProof(());
22
23pub const MAX_JWT_TOKEN_BYTES: usize = 16 * 1024;
24
25#[derive(Debug, Clone, PartialEq, Eq, Error)]
26pub enum AuthenticationError {
27    #[error("invalid JWT format")]
28    InvalidJwtFormat,
29    #[error("JWT token exceeds maximum byte length")]
30    TokenTooLarge { actual: usize, limit: usize },
31    #[error("invalid base64 encoding")]
32    InvalidBase64Encoding,
33    #[error("invalid JSON payload")]
34    InvalidJsonPayload,
35    #[error("unsupported JWT algorithm: {0}")]
36    UnsupportedAlgorithm(String),
37    #[error("invalid JWT signature")]
38    InvalidSignature,
39    #[error("token expired")]
40    TokenExpired,
41    #[error("token not valid yet")]
42    TokenNotYetValid,
43    #[error("token issued in the future")]
44    TokenIssuedInFuture,
45}
46
47/// Local skew guard for `iat`.
48///
49/// RFC 7519 defines `iat` as an informational registered claim, so this
50/// tolerance remains a runtime hardening policy rather than an RFC-mandated
51/// validity rule.
52const MAX_IAT_FUTURE_SKEW: Duration = Duration::from_mins(1);
53
54#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
55pub struct HttpRoomClaims {
56    #[serde(flatten)]
57    pub registered: RegisteredJwtClaims,
58    #[serde(skip_serializing_if = "Option::is_none")]
59    pub key: Option<String>,
60    #[serde(rename = "keySeed", skip_serializing_if = "Option::is_none")]
61    pub key_seed: Option<String>,
62}
63
64#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
65pub struct HttpDisconnectClaims {
66    #[serde(flatten)]
67    pub registered: RegisteredJwtClaims,
68    #[serde(rename = "userIdsByRoom", alias = "sessionIdsByChannel")]
69    pub user_ids_by_room: BTreeMap<String, Vec<UserId>>,
70}
71
72impl HttpDisconnectClaims {
73    pub fn normalize_runtime_user_ids(&mut self) {
74        for user_ids in self.user_ids_by_room.values_mut() {
75            for user_id in user_ids {
76                *user_id = user_id.runtime_normalized();
77            }
78        }
79    }
80}
81
82#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
83pub struct WebSocketConnectClaims {
84    #[serde(flatten)]
85    pub registered: RegisteredJwtClaims,
86    #[serde(rename = "room_id", alias = "sfu_channel_uuid")]
87    pub room_id: String,
88    #[serde(rename = "user_id", alias = "session_id")]
89    pub user_id: UserId,
90    #[serde(skip_serializing_if = "Option::is_none")]
91    pub label: Option<String>,
92    #[serde(skip_serializing_if = "Option::is_none")]
93    pub permissions: Option<UserPermissions>,
94}
95
96impl WebSocketConnectClaims {
97    pub fn normalize_runtime_user_id(&mut self) {
98        self.user_id = self.user_id.runtime_normalized();
99    }
100}
101
102#[must_use]
103pub(crate) fn duration_since_epoch() -> Duration {
104    SystemTime::now()
105        .duration_since(UNIX_EPOCH)
106        .unwrap_or(Duration::ZERO)
107}
108
109/// # Errors
110///
111/// Returns an error when the key cannot be decoded or the claims cannot be serialized.
112pub fn sign<T>(claims: &T, key_b64: &str) -> Result<String, AuthenticationError>
113where
114    T: Serialize,
115{
116    let key = decode_key(key_b64)?;
117    let header = JwtHeader {
118        alg: ALGORITHM_HS256.to_owned(),
119        typ: Some(TYPE_JWT.to_owned()),
120    };
121    let header_json =
122        serde_json::to_vec(&header).map_err(|_error| AuthenticationError::InvalidJsonPayload)?;
123    let claims_json =
124        serde_json::to_vec(claims).map_err(|_error| AuthenticationError::InvalidJsonPayload)?;
125    let header_b64 = URL_SAFE_NO_PAD.encode(header_json);
126    let claims_b64 = URL_SAFE_NO_PAD.encode(claims_json);
127    let signed_data = format!("{header_b64}.{claims_b64}");
128    let signature = sign_hs256(signed_data.as_bytes(), &key)?;
129    let signature_b64 = URL_SAFE_NO_PAD.encode(signature);
130    Ok(format!("{signed_data}.{signature_b64}"))
131}
132
133/// # Errors
134///
135/// Returns an error when the token format, segment encoding, signature, or registered claims are
136/// invalid.
137///
138/// This verifier decodes JWT header, payload, and signature segments with the JOSE base64url
139/// alphabet without padding, as required by RFC 7515 / RFC 7519.
140pub fn verify<T>(token: &str, key_b64: &str) -> Result<T, AuthenticationError>
141where
142    T: DeserializeOwned,
143{
144    validate_token_length(token)?;
145    let key = decode_key(key_b64)?;
146    let (header_b64, claims_b64, signature_b64) = split_token(token)?;
147    let header_bytes = decode_jwt_segment(header_b64)?;
148    let header: JwtHeader = serde_json::from_slice(&header_bytes)
149        .map_err(|_error| AuthenticationError::InvalidJsonPayload)?;
150    if header.alg != ALGORITHM_HS256 {
151        return Err(AuthenticationError::UnsupportedAlgorithm(header.alg));
152    }
153    let actual_signature = decode_jwt_segment(signature_b64)?;
154    verify_hs256(
155        format!("{header_b64}.{claims_b64}").as_bytes(),
156        &key,
157        &actual_signature,
158    )?;
159    let claims_bytes = decode_jwt_segment(claims_b64)?;
160    let registered_claims: RegisteredJwtClaims = serde_json::from_slice(&claims_bytes)
161        .map_err(|_error| AuthenticationError::InvalidJsonPayload)?;
162    validate_registered_claims(&registered_claims)?;
163    serde_json::from_slice(&claims_bytes).map_err(|_error| AuthenticationError::InvalidJsonPayload)
164}
165
166/// Returns verified claims with proof or the [`AuthenticationError`] from [`verify`].
167pub(super) fn verify_with_proof<T: DeserializeOwned>(
168    token: &str,
169    key_b64: &str,
170) -> Result<(T, AuthProof), AuthenticationError> {
171    verify(token, key_b64).map(|claims| (claims, AuthProof(())))
172}
173
174/// decode untrusted JWT claims for candidate room selection only
175///
176/// callers must verify the same token with the selected room key before using
177/// the decoded claims as authenticated identity or permission data
178pub(crate) fn decode_unverified_claims<T>(token: &str) -> Result<T, AuthenticationError>
179where
180    T: DeserializeOwned,
181{
182    validate_token_length(token)?;
183    let (_header_b64, claims_b64, _signature_b64) = split_token(token)?;
184    let claims_bytes = decode_jwt_segment(claims_b64)?;
185    serde_json::from_slice(&claims_bytes).map_err(|_error| AuthenticationError::InvalidJsonPayload)
186}
187
188fn validate_token_length(token: &str) -> Result<(), AuthenticationError> {
189    if token.len() > MAX_JWT_TOKEN_BYTES {
190        return Err(AuthenticationError::TokenTooLarge {
191            actual: token.len(),
192            limit: MAX_JWT_TOKEN_BYTES,
193        });
194    }
195    Ok(())
196}
197
198fn validate_registered_claims(claims: &RegisteredJwtClaims) -> Result<(), AuthenticationError> {
199    validate_registered_claims_at(claims, duration_since_epoch())
200}
201
202fn validate_registered_claims_at(
203    claims: &RegisteredJwtClaims,
204    now: Duration,
205) -> Result<(), AuthenticationError> {
206    let iat_limit = NumericDate::from(now.saturating_add(MAX_IAT_FUTURE_SKEW));
207    let now = NumericDate::from(now);
208    if claims.exp.is_some_and(|exp| exp <= now) {
209        return Err(AuthenticationError::TokenExpired);
210    }
211    if claims.nbf.is_some_and(|nbf| nbf > now) {
212        return Err(AuthenticationError::TokenNotYetValid);
213    }
214    if claims.iat.is_some_and(|iat| iat > iat_limit) {
215        return Err(AuthenticationError::TokenIssuedInFuture);
216    }
217    Ok(())
218}
219
220fn sign_hs256(data: &[u8], key: &[u8]) -> Result<Vec<u8>, AuthenticationError> {
221    let mut mac = HmacSha256::new_from_slice(key)
222        .map_err(|_error| AuthenticationError::InvalidBase64Encoding)?;
223    mac.update(data);
224    Ok(mac.finalize().into_bytes().to_vec())
225}
226
227fn verify_hs256(data: &[u8], key: &[u8], signature: &[u8]) -> Result<(), AuthenticationError> {
228    let mut mac = HmacSha256::new_from_slice(key)
229        .map_err(|_error| AuthenticationError::InvalidBase64Encoding)?;
230    mac.update(data);
231    mac.verify_slice(signature)
232        .map_err(|_error| AuthenticationError::InvalidSignature)
233}
234
235fn split_token(token: &str) -> Result<(&str, &str, &str), AuthenticationError> {
236    let mut parts = token.split('.');
237    let (Some(header), Some(claims), Some(signature), None) =
238        (parts.next(), parts.next(), parts.next(), parts.next())
239    else {
240        return Err(AuthenticationError::InvalidJwtFormat);
241    };
242    if header.is_empty() || claims.is_empty() || signature.is_empty() {
243        return Err(AuthenticationError::InvalidJwtFormat);
244    }
245    Ok((header, claims, signature))
246}
247
248pub(crate) fn decode_key(input: &str) -> Result<Vec<u8>, AuthenticationError> {
249    let padded = pad_base64(input);
250    URL_SAFE
251        .decode(padded.as_bytes())
252        .or_else(|_error| STANDARD.decode(padded.as_bytes()))
253        .map_err(|_error| AuthenticationError::InvalidBase64Encoding)
254}
255
256fn decode_jwt_segment(input: &str) -> Result<Vec<u8>, AuthenticationError> {
257    URL_SAFE_NO_PAD
258        .decode(input.as_bytes())
259        .map_err(|_error| AuthenticationError::InvalidBase64Encoding)
260}
261
262fn pad_base64(input: &str) -> String {
263    let remainder = input.len() % 4;
264    if remainder == 0 {
265        return input.to_owned();
266    }
267    format!("{input}{}", "=".repeat(4 - remainder))
268}
269
270pub(crate) fn derive_key_from_seed(key: &str, seed: &str) -> Result<String, AuthenticationError> {
271    let key_bytes = decode_key(key)?;
272    let seed_bytes = decode_key(seed)?;
273    let derived_key = sign_hs256(&seed_bytes, &key_bytes)?;
274    Ok(STANDARD.encode(derived_key))
275}
276
277#[cfg(test)]
278#[path = "TESTS/auth.rs"]
279mod tests;