Merge pull request 'feat(video): short-lived signed URLs for original-quality playback' (#168) from feat/signed-original-video into master
Reviewed-on: #168
This commit was merged in pull request #168.
This commit is contained in:
commit
40d9dcdbb6
5 files changed
+345
-29
No files matched your search
@@ -525,9 +525,13 @@ GET /photos?path=...&recursive=true&sort=DateTakenDesc&camera_make=Canon&gps_lat
|
|||||||
// ExifDao::query_by_exif — shared by /photos, /photos/exif and unified search.
|
// ExifDao::query_by_exif — shared by /photos, /photos/exif and unified search.
|
||||||
// A calendar-day range = that day's bounds in the owner's display zone.
|
// A calendar-day range = that day's bounds in the owner's display zone.
|
||||||
|
|
||||||
// Video streaming (HLS)
|
// Video streaming (HLS, the default playback path)
|
||||||
POST /video/generate (creates .m3u8 playlist + .ts segments)
|
POST /video/generate (queues .m3u8 playlist + .ts segments, returns playlist_url)
|
||||||
GET /video/stream?path=... (serves playlist)
|
GET /video/hls/<hash>/<file> (serves playlist / segments)
|
||||||
|
|
||||||
|
// Original-quality video (opt-in; short-lived signed URL, no auth header needed)
|
||||||
|
POST /video/original (body: {path, library}) -> {url, expires_at}
|
||||||
|
GET /video/original/stream?token=... (Range-capable original bytes)
|
||||||
|
|
||||||
// Tags
|
// Tags
|
||||||
GET /image/tags/all
|
GET /image/tags/all
|
||||||
|
|||||||
+216
-26
@@ -14,7 +14,7 @@ use actix_web::{
|
|||||||
use log::{debug, error, info, warn};
|
use log::{debug, error, info, warn};
|
||||||
use opentelemetry::KeyValue;
|
use opentelemetry::KeyValue;
|
||||||
use opentelemetry::trace::Status;
|
use opentelemetry::trace::Status;
|
||||||
use serde::Serialize;
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
use crate::content_hash;
|
use crate::content_hash;
|
||||||
use crate::data::{
|
use crate::data::{
|
||||||
@@ -22,6 +22,7 @@ use crate::data::{
|
|||||||
ThumbnailRequest,
|
ThumbnailRequest,
|
||||||
};
|
};
|
||||||
use crate::database::{ExifDao, PreviewDao};
|
use crate::database::{ExifDao, PreviewDao};
|
||||||
|
use crate::file_types;
|
||||||
use crate::files::is_valid_full_path;
|
use crate::files::is_valid_full_path;
|
||||||
use crate::libraries;
|
use crate::libraries;
|
||||||
use crate::state::AppState;
|
use crate::state::AppState;
|
||||||
@@ -29,6 +30,7 @@ use crate::video::actors::{
|
|||||||
GeneratePreviewClipMessage, QueueVideosMessage, VideoToQueue, probe_video_stream_meta,
|
GeneratePreviewClipMessage, QueueVideosMessage, VideoToQueue, probe_video_stream_meta,
|
||||||
};
|
};
|
||||||
use crate::video::hls_paths;
|
use crate::video::hls_paths;
|
||||||
|
use crate::video::original_url;
|
||||||
|
|
||||||
/// Response body for `POST /video/generate`. Clients consume
|
/// Response body for `POST /video/generate`. Clients consume
|
||||||
/// `playlist_url` (hash-keyed, stable across libraries and renames)
|
/// `playlist_url` (hash-keyed, stable across libraries and renames)
|
||||||
@@ -65,31 +67,7 @@ pub async fn generate_video(
|
|||||||
) -> impl Responder {
|
) -> impl Responder {
|
||||||
let span = tracing::Span::current();
|
let span = tracing::Span::current();
|
||||||
|
|
||||||
let preferred_library =
|
let resolved = resolve_request_path(&app_state, &body.path, body.library.as_deref());
|
||||||
libraries::resolve_library_param_state(&app_state, body.library.as_deref())
|
|
||||||
.ok()
|
|
||||||
.flatten()
|
|
||||||
.unwrap_or_else(|| app_state.primary_library());
|
|
||||||
|
|
||||||
// Try the resolved library first, then fall back to any other library
|
|
||||||
// that actually contains the file — handles union-mode requests where
|
|
||||||
// the mobile client passes no library but the file lives in a
|
|
||||||
// non-primary library. Track which library won so the DB lookup is
|
|
||||||
// scoped correctly.
|
|
||||||
let resolved = is_valid_full_path(&preferred_library.root_path, &body.path, false)
|
|
||||||
.filter(|p| p.exists())
|
|
||||||
.map(|p| (preferred_library.id, preferred_library.root_path.clone(), p))
|
|
||||||
.or_else(|| {
|
|
||||||
app_state.libraries.iter().find_map(|lib| {
|
|
||||||
if lib.id == preferred_library.id {
|
|
||||||
return None;
|
|
||||||
}
|
|
||||||
is_valid_full_path(&lib.root_path, &body.path, false)
|
|
||||||
.filter(|p| p.exists())
|
|
||||||
.map(|p| (lib.id, lib.root_path.clone(), p))
|
|
||||||
})
|
|
||||||
});
|
|
||||||
|
|
||||||
let Some((resolved_library_id, resolved_root, full_path)) = resolved else {
|
let Some((resolved_library_id, resolved_root, full_path)) = resolved else {
|
||||||
span.set_status(Status::error(format!("invalid path {:?}", &body.path)));
|
span.set_status(Status::error(format!("invalid path {:?}", &body.path)));
|
||||||
return HttpResponse::BadRequest().finish();
|
return HttpResponse::BadRequest().finish();
|
||||||
@@ -284,6 +262,126 @@ fn is_valid_hash(s: &str) -> bool {
|
|||||||
/// `Melissa\clip.mp4`. `get_exif_batch` does an exact match with no
|
/// `Melissa\clip.mp4`. `get_exif_batch` does an exact match with no
|
||||||
/// normalization, so the backslash form misses and the handler falls back
|
/// normalization, so the backslash form misses and the handler falls back
|
||||||
/// to re-hashing the entire file on every request.
|
/// to re-hashing the entire file on every request.
|
||||||
|
/// Response body for `POST /video/original`.
|
||||||
|
#[derive(Serialize, Debug)]
|
||||||
|
struct OriginalVideoUrlResponse {
|
||||||
|
/// `/video/original/stream?token=…` — works without an Authorization
|
||||||
|
/// header, so it can go straight into a native player.
|
||||||
|
url: String,
|
||||||
|
/// Unix seconds. Clients should mint a fresh URL rather than reuse one
|
||||||
|
/// past this.
|
||||||
|
expires_at: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Mint a short-lived URL for streaming the original bytes of a video.
|
||||||
|
/// HLS (`/video/generate`) stays the default playback path; this is the
|
||||||
|
/// opt-in full-quality path for clients on a fast network.
|
||||||
|
#[post("/video/original")]
|
||||||
|
#[tracing::instrument(name = "original_video_url", skip_all)]
|
||||||
|
pub async fn original_video_url(
|
||||||
|
claims: Claims,
|
||||||
|
app_state: Data<AppState>,
|
||||||
|
body: web::Json<ThumbnailRequest>,
|
||||||
|
) -> impl Responder {
|
||||||
|
let span = tracing::Span::current();
|
||||||
|
|
||||||
|
let Some((library_id, root, full_path)) =
|
||||||
|
resolve_request_path(&app_state, &body.path, body.library.as_deref())
|
||||||
|
else {
|
||||||
|
span.set_status(Status::error(format!("invalid path {:?}", &body.path)));
|
||||||
|
return HttpResponse::BadRequest().finish();
|
||||||
|
};
|
||||||
|
if !file_types::is_video_file(&full_path) {
|
||||||
|
span.set_status(Status::error("not a video"));
|
||||||
|
return HttpResponse::BadRequest().body("not a video");
|
||||||
|
}
|
||||||
|
|
||||||
|
let rel_path = rel_path_for_lookup(&full_path.to_string_lossy(), &root);
|
||||||
|
let (token, expires_at) = original_url::sign(&claims.sub, library_id, &rel_path);
|
||||||
|
|
||||||
|
span.set_status(Status::Ok);
|
||||||
|
HttpResponse::Ok().json(OriginalVideoUrlResponse {
|
||||||
|
url: format!("/video/original/stream?token={}", token),
|
||||||
|
expires_at,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct OriginalStreamQuery {
|
||||||
|
token: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Stream an original video named by a token from `POST /video/original`.
|
||||||
|
/// Authenticated by the token alone (no Claims). `NamedFile` honours Range,
|
||||||
|
/// so players can seek.
|
||||||
|
#[get("/video/original/stream")]
|
||||||
|
#[tracing::instrument(name = "stream_original_video", skip_all)]
|
||||||
|
pub async fn stream_original_video(
|
||||||
|
request: HttpRequest,
|
||||||
|
query: web::Query<OriginalStreamQuery>,
|
||||||
|
app_state: Data<AppState>,
|
||||||
|
) -> impl Responder {
|
||||||
|
let span = tracing::Span::current();
|
||||||
|
|
||||||
|
let Some(claims) = original_url::verify(&query.token) else {
|
||||||
|
span.set_status(Status::error("bad token"));
|
||||||
|
return HttpResponse::Unauthorized().finish();
|
||||||
|
};
|
||||||
|
let Some(path) = app_state
|
||||||
|
.libraries
|
||||||
|
.iter()
|
||||||
|
.find(|lib| lib.id == claims.lib)
|
||||||
|
.and_then(|lib| is_valid_full_path(&lib.root_path, &claims.path, false))
|
||||||
|
.filter(|p| p.is_file())
|
||||||
|
else {
|
||||||
|
span.set_status(Status::error("not found"));
|
||||||
|
return HttpResponse::NotFound().finish();
|
||||||
|
};
|
||||||
|
|
||||||
|
match NamedFile::open(&path) {
|
||||||
|
Ok(f) => {
|
||||||
|
span.set_status(Status::Ok);
|
||||||
|
f.use_etag(true)
|
||||||
|
.use_last_modified(true)
|
||||||
|
.into_response(&request)
|
||||||
|
}
|
||||||
|
Err(_) => {
|
||||||
|
span.set_status(Status::error("not found"));
|
||||||
|
HttpResponse::NotFound().finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resolve a client `path` + optional `library` to `(library_id, root,
|
||||||
|
/// full_path)`. Tries the requested library first, then falls back to any
|
||||||
|
/// other library that actually contains the file — handles union-mode
|
||||||
|
/// requests where the mobile client passes no library but the file lives
|
||||||
|
/// in a non-primary library.
|
||||||
|
fn resolve_request_path(
|
||||||
|
app_state: &AppState,
|
||||||
|
path: &str,
|
||||||
|
library: Option<&str>,
|
||||||
|
) -> Option<(i32, String, PathBuf)> {
|
||||||
|
let preferred_library = libraries::resolve_library_param_state(app_state, library)
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.unwrap_or_else(|| app_state.primary_library());
|
||||||
|
|
||||||
|
is_valid_full_path(&preferred_library.root_path, &path.to_string(), false)
|
||||||
|
.filter(|p| p.exists())
|
||||||
|
.map(|p| (preferred_library.id, preferred_library.root_path.clone(), p))
|
||||||
|
.or_else(|| {
|
||||||
|
app_state.libraries.iter().find_map(|lib| {
|
||||||
|
if lib.id == preferred_library.id {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
is_valid_full_path(&lib.root_path, &path.to_string(), false)
|
||||||
|
.filter(|p| p.exists())
|
||||||
|
.map(|p| (lib.id, lib.root_path.clone(), p))
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn rel_path_for_lookup(full_path_str: &str, resolved_root: &str) -> String {
|
fn rel_path_for_lookup(full_path_str: &str, resolved_root: &str) -> String {
|
||||||
full_path_str
|
full_path_str
|
||||||
.strip_prefix(resolved_root)
|
.strip_prefix(resolved_root)
|
||||||
@@ -635,6 +733,98 @@ mod tests {
|
|||||||
.unwrap()
|
.unwrap()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn write_library_file(app_state: &AppState, rel: &str, bytes: &[u8]) {
|
||||||
|
let path = std::path::Path::new(&app_state.primary_library().root_path).join(rel);
|
||||||
|
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
|
||||||
|
std::fs::write(path, bytes).unwrap();
|
||||||
|
}
|
||||||
|
|
||||||
|
#[actix_rt::test]
|
||||||
|
async fn original_video_url_streams_with_range_and_no_auth_header() {
|
||||||
|
let app_state = Data::new(AppState::test_state());
|
||||||
|
write_library_file(&app_state, "clips/a.mp4", b"0123456789");
|
||||||
|
let app = actix_web::test::init_service(
|
||||||
|
App::new()
|
||||||
|
.service(original_video_url)
|
||||||
|
.service(stream_original_video)
|
||||||
|
.app_data(app_state),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let req = actix_web::test::TestRequest::post()
|
||||||
|
.uri("/video/original")
|
||||||
|
.insert_header(("Authorization", format!("Bearer {}", make_token())))
|
||||||
|
.set_json(serde_json::json!({"path": "clips/a.mp4"}))
|
||||||
|
.to_request();
|
||||||
|
let resp = actix_web::test::call_service(&app, req).await;
|
||||||
|
assert_eq!(resp.status(), 200);
|
||||||
|
let body: serde_json::Value = actix_web::test::read_body_json(resp).await;
|
||||||
|
let url = body["url"].as_str().unwrap().to_string();
|
||||||
|
assert!(url.starts_with("/video/original/stream?token="));
|
||||||
|
assert!(body["expires_at"].as_i64().unwrap() > chrono::Utc::now().timestamp());
|
||||||
|
|
||||||
|
// No Authorization header: the token alone grants access.
|
||||||
|
let req = actix_web::test::TestRequest::get()
|
||||||
|
.uri(&url)
|
||||||
|
.insert_header(("Range", "bytes=2-5"))
|
||||||
|
.to_request();
|
||||||
|
let resp = actix_web::test::call_service(&app, req).await;
|
||||||
|
assert_eq!(resp.status(), 206);
|
||||||
|
assert_eq!(actix_web::test::read_body(resp).await.as_ref(), b"2345");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[actix_rt::test]
|
||||||
|
async fn original_video_url_rejects_non_video_and_unauthenticated() {
|
||||||
|
let app_state = Data::new(AppState::test_state());
|
||||||
|
write_library_file(&app_state, "notes.txt", b"hi");
|
||||||
|
let app = actix_web::test::init_service(
|
||||||
|
App::new().service(original_video_url).app_data(app_state),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let req = actix_web::test::TestRequest::post()
|
||||||
|
.uri("/video/original")
|
||||||
|
.insert_header(("Authorization", format!("Bearer {}", make_token())))
|
||||||
|
.set_json(serde_json::json!({"path": "notes.txt"}))
|
||||||
|
.to_request();
|
||||||
|
assert_eq!(actix_web::test::call_service(&app, req).await.status(), 400);
|
||||||
|
|
||||||
|
let req = actix_web::test::TestRequest::post()
|
||||||
|
.uri("/video/original")
|
||||||
|
.set_json(serde_json::json!({"path": "notes.txt"}))
|
||||||
|
.to_request();
|
||||||
|
assert_eq!(actix_web::test::call_service(&app, req).await.status(), 401);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[actix_rt::test]
|
||||||
|
async fn stream_original_video_rejects_bad_tokens() {
|
||||||
|
let app_state = Data::new(AppState::test_state());
|
||||||
|
write_library_file(&app_state, "clips/a.mp4", b"0123456789");
|
||||||
|
let app = actix_web::test::init_service(
|
||||||
|
App::new()
|
||||||
|
.service(stream_original_video)
|
||||||
|
.app_data(app_state),
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// A login JWT is not a stream token.
|
||||||
|
let req = actix_web::test::TestRequest::get()
|
||||||
|
.uri(&format!("/video/original/stream?token={}", make_token()))
|
||||||
|
.to_request();
|
||||||
|
assert_eq!(actix_web::test::call_service(&app, req).await.status(), 401);
|
||||||
|
|
||||||
|
// A valid token for a path that escapes the library root.
|
||||||
|
let (token, _) = original_url::sign(
|
||||||
|
"1",
|
||||||
|
crate::libraries::PRIMARY_LIBRARY_ID,
|
||||||
|
"../../etc/passwd",
|
||||||
|
);
|
||||||
|
let req = actix_web::test::TestRequest::get()
|
||||||
|
.uri(&format!("/video/original/stream?token={}", token))
|
||||||
|
.to_request();
|
||||||
|
assert_eq!(actix_web::test::call_service(&app, req).await.status(), 404);
|
||||||
|
}
|
||||||
|
|
||||||
fn make_preview_dao(dao: TestPreviewDao) -> Data<Mutex<Box<dyn PreviewDao>>> {
|
fn make_preview_dao(dao: TestPreviewDao) -> Data<Mutex<Box<dyn PreviewDao>>> {
|
||||||
Data::new(Mutex::new(Box::new(dao) as Box<dyn PreviewDao>))
|
Data::new(Mutex::new(Box::new(dao) as Box<dyn PreviewDao>))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -352,6 +352,8 @@ fn main() -> std::io::Result<()> {
|
|||||||
.service(handlers::image::upload_image)
|
.service(handlers::image::upload_image)
|
||||||
.service(handlers::video::generate_video)
|
.service(handlers::video::generate_video)
|
||||||
.service(handlers::video::stream_hls_file)
|
.service(handlers::video::stream_hls_file)
|
||||||
|
.service(handlers::video::original_video_url)
|
||||||
|
.service(handlers::video::stream_original_video)
|
||||||
.service(handlers::video::get_video_preview)
|
.service(handlers::video::get_video_preview)
|
||||||
.service(handlers::video::get_preview_status)
|
.service(handlers::video::get_preview_status)
|
||||||
.service(hls_stats::hls_stats_handler)
|
.service(hls_stats::hls_stats_handler)
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ pub mod actors;
|
|||||||
pub mod ffmpeg;
|
pub mod ffmpeg;
|
||||||
pub mod hls_paths;
|
pub mod hls_paths;
|
||||||
pub mod legacy_migration;
|
pub mod legacy_migration;
|
||||||
|
pub mod original_url;
|
||||||
|
|
||||||
#[allow(dead_code)]
|
#[allow(dead_code)]
|
||||||
pub async fn generate_video_gifs() {
|
pub async fn generate_video_gifs() {
|
||||||
|
|||||||
@@ -0,0 +1,119 @@
|
|||||||
|
//! Short-lived signed URLs for streaming an original video file.
|
||||||
|
//!
|
||||||
|
//! Native players (`<video src>`, the mobile player's direct-URL mode) can't
|
||||||
|
//! attach an `Authorization` header. `POST /video/original` (authenticated)
|
||||||
|
//! mints a URL whose token names one library + rel_path and an expiry;
|
||||||
|
//! `GET /video/original/stream?token=` accepts only that token, so a leaked
|
||||||
|
//! URL grants a single file for a few hours rather than the whole API.
|
||||||
|
|
||||||
|
use chrono::{Duration, Utc};
|
||||||
|
use jsonwebtoken::{Algorithm, DecodingKey, EncodingKey, Header, Validation, decode, encode};
|
||||||
|
use log::debug;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
use crate::data::secret_key;
|
||||||
|
|
||||||
|
/// Long enough to watch (and seek around) a long video in one sitting;
|
||||||
|
/// players keep issuing Range requests against the same URL the whole time.
|
||||||
|
pub const ORIGINAL_URL_TTL_HOURS: i64 = 6;
|
||||||
|
|
||||||
|
#[derive(Debug, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct OriginalVideoClaims {
|
||||||
|
pub sub: String,
|
||||||
|
pub lib: i32,
|
||||||
|
pub path: String,
|
||||||
|
pub exp: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Derived from SECRET_KEY but distinct from the login-token key, so a
|
||||||
|
/// login JWT can't be replayed as a stream token or the reverse.
|
||||||
|
fn signing_key() -> Vec<u8> {
|
||||||
|
format!("{}:video-original", secret_key()).into_bytes()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Returns the token and its expiry (unix seconds).
|
||||||
|
pub fn sign(sub: &str, library_id: i32, rel_path: &str) -> (String, i64) {
|
||||||
|
let exp = (Utc::now() + Duration::hours(ORIGINAL_URL_TTL_HOURS)).timestamp();
|
||||||
|
let claims = OriginalVideoClaims {
|
||||||
|
sub: sub.to_string(),
|
||||||
|
lib: library_id,
|
||||||
|
path: rel_path.to_string(),
|
||||||
|
exp,
|
||||||
|
};
|
||||||
|
let token = encode(
|
||||||
|
&Header::new(Algorithm::HS256),
|
||||||
|
&claims,
|
||||||
|
&EncodingKey::from_secret(&signing_key()),
|
||||||
|
)
|
||||||
|
.expect("HS256 encoding of plain claims cannot fail");
|
||||||
|
(token, exp)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn verify(token: &str) -> Option<OriginalVideoClaims> {
|
||||||
|
decode::<OriginalVideoClaims>(
|
||||||
|
token,
|
||||||
|
&DecodingKey::from_secret(&signing_key()),
|
||||||
|
&Validation::new(Algorithm::HS256),
|
||||||
|
)
|
||||||
|
.map(|data| data.claims)
|
||||||
|
.map_err(|e| debug!("Rejected original-video token: {}", e))
|
||||||
|
.ok()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::data::Claims;
|
||||||
|
use std::str::FromStr;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn round_trips_library_and_path() {
|
||||||
|
let (token, exp) = sign("1", 3, "2024/clip.mp4");
|
||||||
|
let claims = verify(&token).expect("fresh token verifies");
|
||||||
|
assert_eq!(claims.lib, 3);
|
||||||
|
assert_eq!(claims.path, "2024/clip.mp4");
|
||||||
|
assert_eq!(claims.exp, exp);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_tampered_token() {
|
||||||
|
let (token, _) = sign("1", 3, "2024/clip.mp4");
|
||||||
|
let mut parts: Vec<&str> = token.split('.').collect();
|
||||||
|
let (forged, _) = sign("1", 3, "2024/other.mp4");
|
||||||
|
// Swap in another token's payload under the original signature.
|
||||||
|
let forged_payload = forged.split('.').nth(1).unwrap().to_string();
|
||||||
|
parts[1] = &forged_payload;
|
||||||
|
assert!(verify(&parts.join(".")).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_expired_token() {
|
||||||
|
let claims = OriginalVideoClaims {
|
||||||
|
sub: "1".into(),
|
||||||
|
lib: 1,
|
||||||
|
path: "clip.mp4".into(),
|
||||||
|
exp: (Utc::now() - Duration::hours(1)).timestamp(),
|
||||||
|
};
|
||||||
|
let token = encode(
|
||||||
|
&Header::new(Algorithm::HS256),
|
||||||
|
&claims,
|
||||||
|
&EncodingKey::from_secret(&signing_key()),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(verify(&token).is_none());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn keys_are_not_interchangeable_with_login_tokens() {
|
||||||
|
let login = encode(
|
||||||
|
&Header::default(),
|
||||||
|
&Claims::valid_user("1".into()),
|
||||||
|
&EncodingKey::from_secret(secret_key().as_bytes()),
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
assert!(verify(&login).is_none());
|
||||||
|
|
||||||
|
let (stream_token, _) = sign("1", 1, "clip.mp4");
|
||||||
|
assert!(Claims::from_str(&format!("Bearer {}", stream_token)).is_err());
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in new issue
Block a user