chore: pin JWT validation parameters
This commit is contained in:
parent
bcfd15ee91
commit
062cec5dfe
5 changed files with 56 additions and 17 deletions
|
|
@ -186,7 +186,7 @@ Configure environment variables in `wrangler.toml` under `[vars]`, or set them v
|
||||||
* **`ATTACHMENT_TOTAL_LIMIT_KB`** (Optional):
|
* **`ATTACHMENT_TOTAL_LIMIT_KB`** (Optional):
|
||||||
- Max total attachment storage per user in KB.
|
- Max total attachment storage per user in KB.
|
||||||
- Example: `1048576` for 1GB.
|
- Example: `1048576` for 1GB.
|
||||||
* **`ATTACHMENT_TTL_SECS`** (Optional, Default: `300`):
|
* **`ATTACHMENT_TTL_SECS`** (Optional, Default: `300`, Minimum: `60`):
|
||||||
- TTL for attachment upload/download URLs.
|
- TTL for attachment upload/download URLs.
|
||||||
|
|
||||||
### Scheduled Tasks (Cron)
|
### Scheduled Tasks (Cron)
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,8 @@
|
||||||
* Route matching and URL parsing should be handled by `src/entry.js`.
|
* Route matching and URL parsing should be handled by `src/entry.js`.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
const JWT_EXPECTED_ALG = "HS256";
|
||||||
|
const JWT_VALIDATION_LEEWAY_SECS = 60;
|
||||||
// JWT validation using Web Crypto API (no external dependencies)
|
// JWT validation using Web Crypto API (no external dependencies)
|
||||||
async function verifyJwt(token, secret) {
|
async function verifyJwt(token, secret) {
|
||||||
const encoder = new TextEncoder();
|
const encoder = new TextEncoder();
|
||||||
|
|
@ -19,6 +21,17 @@ async function verifyJwt(token, secret) {
|
||||||
|
|
||||||
const [headerB64, payloadB64, signatureB64] = parts;
|
const [headerB64, payloadB64, signatureB64] = parts;
|
||||||
|
|
||||||
|
let header;
|
||||||
|
try {
|
||||||
|
header = JSON.parse(new TextDecoder().decode(base64UrlDecode(headerB64)));
|
||||||
|
} catch {
|
||||||
|
throw new Error("Invalid token header");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!header || typeof header !== "object" || header.alg !== JWT_EXPECTED_ALG) {
|
||||||
|
throw new Error("Invalid token algorithm");
|
||||||
|
}
|
||||||
|
|
||||||
// Import the secret key for HMAC-SHA256
|
// Import the secret key for HMAC-SHA256
|
||||||
const key = await crypto.subtle.importKey(
|
const key = await crypto.subtle.importKey(
|
||||||
"raw",
|
"raw",
|
||||||
|
|
@ -40,12 +53,23 @@ async function verifyJwt(token, secret) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Decode and parse the payload
|
// Decode and parse the payload
|
||||||
const payload = JSON.parse(
|
let payload;
|
||||||
new TextDecoder().decode(base64UrlDecode(payloadB64))
|
try {
|
||||||
);
|
payload = JSON.parse(new TextDecoder().decode(base64UrlDecode(payloadB64)));
|
||||||
|
} catch {
|
||||||
|
throw new Error("Invalid token payload");
|
||||||
|
}
|
||||||
|
|
||||||
// Check expiration
|
if (!payload || typeof payload !== "object") {
|
||||||
if (payload.exp && payload.exp < Math.floor(Date.now() / 1000)) {
|
throw new Error("Invalid token payload");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (typeof payload.exp !== "number") {
|
||||||
|
throw new Error("Invalid token exp");
|
||||||
|
}
|
||||||
|
|
||||||
|
const now = Math.floor(Date.now() / 1000);
|
||||||
|
if (payload.exp < now - JWT_VALIDATION_LEEWAY_SECS) {
|
||||||
throw new Error("Token expired");
|
throw new Error("Token expired");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -442,5 +466,3 @@ export async function handleDownload(request, env, cipherId, attachmentId, token
|
||||||
headers,
|
headers,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
20
src/auth.rs
20
src/auth.rs
|
|
@ -3,14 +3,17 @@ use axum::{
|
||||||
http::{header, request::Parts},
|
http::{header, request::Parts},
|
||||||
};
|
};
|
||||||
use constant_time_eq::constant_time_eq;
|
use constant_time_eq::constant_time_eq;
|
||||||
use jsonwebtoken::{decode, DecodingKey, Validation};
|
use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashSet;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use worker::Env;
|
use worker::Env;
|
||||||
|
|
||||||
use crate::db;
|
use crate::db;
|
||||||
use crate::error::AppError;
|
use crate::error::AppError;
|
||||||
|
|
||||||
|
pub(crate) const JWT_VALIDATION_LEEWAY_SECS: u64 = 60;
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
pub struct Claims {
|
pub struct Claims {
|
||||||
pub sub: String, // User ID
|
pub sub: String, // User ID
|
||||||
|
|
@ -25,6 +28,19 @@ pub struct Claims {
|
||||||
pub amr: Vec<String>,
|
pub amr: Vec<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn jwt_validation() -> Validation {
|
||||||
|
let mut required_spec_claims = HashSet::new();
|
||||||
|
required_spec_claims.insert("exp".to_string());
|
||||||
|
|
||||||
|
let mut validation = Validation::new(Algorithm::HS256);
|
||||||
|
validation.required_spec_claims = required_spec_claims;
|
||||||
|
validation.leeway = JWT_VALIDATION_LEEWAY_SECS;
|
||||||
|
validation.validate_exp = true;
|
||||||
|
validation.validate_nbf = true;
|
||||||
|
validation.algorithms = vec![Algorithm::HS256];
|
||||||
|
validation
|
||||||
|
}
|
||||||
|
|
||||||
/// AuthUser extractor - provides (user_id, email) tuple
|
/// AuthUser extractor - provides (user_id, email) tuple
|
||||||
pub struct AuthUser(
|
pub struct AuthUser(
|
||||||
pub String, // user_id
|
pub String, // user_id
|
||||||
|
|
@ -55,7 +71,7 @@ impl FromRequestParts<Arc<Env>> for Claims {
|
||||||
|
|
||||||
// Decode and validate the token
|
// Decode and validate the token
|
||||||
let decoding_key = DecodingKey::from_secret(secret.to_string().as_ref());
|
let decoding_key = DecodingKey::from_secret(secret.to_string().as_ref());
|
||||||
let token_data = decode::<Claims>(&token, &decoding_key, &Validation::default())
|
let token_data = decode::<Claims>(&token, &decoding_key, &jwt_validation())
|
||||||
.map_err(|_| AppError::Unauthorized("Invalid token".to_string()))?;
|
.map_err(|_| AppError::Unauthorized("Invalid token".to_string()))?;
|
||||||
|
|
||||||
let claims = token_data.claims;
|
let claims = token_data.claims;
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ use uuid::Uuid;
|
||||||
use worker::{query, wasm_bindgen::JsValue, Bucket, D1Database, Env, HttpMetadata};
|
use worker::{query, wasm_bindgen::JsValue, Bucket, D1Database, Env, HttpMetadata};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
auth::Claims,
|
auth::{Claims, JWT_VALIDATION_LEEWAY_SECS},
|
||||||
db,
|
db,
|
||||||
error::AppError,
|
error::AppError,
|
||||||
models::{
|
models::{
|
||||||
|
|
@ -560,7 +560,7 @@ fn download_url(
|
||||||
attachment_id: &str,
|
attachment_id: &str,
|
||||||
user_id: &str,
|
user_id: &str,
|
||||||
) -> Result<String, AppError> {
|
) -> Result<String, AppError> {
|
||||||
let token = build_download_token(env, user_id, cipher_id, attachment_id)?;
|
let token = build_upload_download_token(env, user_id, cipher_id, attachment_id)?;
|
||||||
let normalized_base = base_url.trim_end_matches('/');
|
let normalized_base = base_url.trim_end_matches('/');
|
||||||
Ok(format!(
|
Ok(format!(
|
||||||
"{normalized_base}/api/ciphers/{cipher_id}/attachment/{attachment_id}/download?token={token}"
|
"{normalized_base}/api/ciphers/{cipher_id}/attachment/{attachment_id}/download?token={token}"
|
||||||
|
|
@ -734,7 +734,7 @@ fn validate_size_within_declared(
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn build_download_token(
|
fn build_upload_download_token(
|
||||||
env: &Env,
|
env: &Env,
|
||||||
user_id: &str,
|
user_id: &str,
|
||||||
cipher_id: &str,
|
cipher_id: &str,
|
||||||
|
|
@ -744,6 +744,7 @@ fn build_download_token(
|
||||||
let now = Utc::now().timestamp();
|
let now = Utc::now().timestamp();
|
||||||
let exp = now
|
let exp = now
|
||||||
.checked_add(ttl_secs)
|
.checked_add(ttl_secs)
|
||||||
|
.and_then(|exp| exp.checked_sub(JWT_VALIDATION_LEEWAY_SECS as i64))
|
||||||
.ok_or_else(|| AppError::Internal)?;
|
.ok_or_else(|| AppError::Internal)?;
|
||||||
|
|
||||||
if exp < 0 {
|
if exp < 0 {
|
||||||
|
|
@ -778,7 +779,7 @@ fn upload_url(
|
||||||
attachment_id: &str,
|
attachment_id: &str,
|
||||||
user_id: &str,
|
user_id: &str,
|
||||||
) -> Result<String, AppError> {
|
) -> Result<String, AppError> {
|
||||||
let token = build_download_token(env, user_id, cipher_id, attachment_id)?;
|
let token = build_upload_download_token(env, user_id, cipher_id, attachment_id)?;
|
||||||
let normalized_base = base_url.trim_end_matches('/');
|
let normalized_base = base_url.trim_end_matches('/');
|
||||||
Ok(format!(
|
Ok(format!(
|
||||||
"{normalized_base}/api/ciphers/{cipher_id}/attachment/{attachment_id}/azure-upload?token={token}"
|
"{normalized_base}/api/ciphers/{cipher_id}/attachment/{attachment_id}/azure-upload?token={token}"
|
||||||
|
|
|
||||||
|
|
@ -1,14 +1,14 @@
|
||||||
use axum::{extract::State, Form, Json};
|
use axum::{extract::State, Form, Json};
|
||||||
use chrono::{Duration, Utc};
|
use chrono::{Duration, Utc};
|
||||||
use constant_time_eq::constant_time_eq;
|
use constant_time_eq::constant_time_eq;
|
||||||
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header, Validation};
|
use jsonwebtoken::{decode, encode, DecodingKey, EncodingKey, Header};
|
||||||
use serde::{Deserialize, Deserializer, Serialize};
|
use serde::{Deserialize, Deserializer, Serialize};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use worker::{query, Env};
|
use worker::{query, Env};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
auth::Claims,
|
auth::{jwt_validation, Claims},
|
||||||
crypto::{ct_eq, generate_salt, hash_password_for_storage, validate_totp},
|
crypto::{ct_eq, generate_salt, hash_password_for_storage, validate_totp},
|
||||||
db,
|
db,
|
||||||
error::AppError,
|
error::AppError,
|
||||||
|
|
@ -494,7 +494,7 @@ pub async fn token(
|
||||||
let token_data = decode::<RefreshClaims>(
|
let token_data = decode::<RefreshClaims>(
|
||||||
&refresh_token,
|
&refresh_token,
|
||||||
&DecodingKey::from_secret(jwt_refresh_secret.as_ref()),
|
&DecodingKey::from_secret(jwt_refresh_secret.as_ref()),
|
||||||
&Validation::default(),
|
&jwt_validation(),
|
||||||
)
|
)
|
||||||
.map_err(|_| AppError::Unauthorized("Invalid refresh token".to_string()))?;
|
.map_err(|_| AppError::Unauthorized("Invalid refresh token".to_string()))?;
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue