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):
|
||||
- Max total attachment storage per user in KB.
|
||||
- Example: `1048576` for 1GB.
|
||||
* **`ATTACHMENT_TTL_SECS`** (Optional, Default: `300`):
|
||||
* **`ATTACHMENT_TTL_SECS`** (Optional, Default: `300`, Minimum: `60`):
|
||||
- TTL for attachment upload/download URLs.
|
||||
|
||||
### Scheduled Tasks (Cron)
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@
|
|||
* 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)
|
||||
async function verifyJwt(token, secret) {
|
||||
const encoder = new TextEncoder();
|
||||
|
|
@ -19,6 +21,17 @@ async function verifyJwt(token, secret) {
|
|||
|
||||
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
|
||||
const key = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
|
|
@ -40,12 +53,23 @@ async function verifyJwt(token, secret) {
|
|||
}
|
||||
|
||||
// Decode and parse the payload
|
||||
const payload = JSON.parse(
|
||||
new TextDecoder().decode(base64UrlDecode(payloadB64))
|
||||
);
|
||||
let payload;
|
||||
try {
|
||||
payload = JSON.parse(new TextDecoder().decode(base64UrlDecode(payloadB64)));
|
||||
} catch {
|
||||
throw new Error("Invalid token payload");
|
||||
}
|
||||
|
||||
// Check expiration
|
||||
if (payload.exp && payload.exp < Math.floor(Date.now() / 1000)) {
|
||||
if (!payload || typeof payload !== "object") {
|
||||
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");
|
||||
}
|
||||
|
||||
|
|
@ -442,5 +466,3 @@ export async function handleDownload(request, env, cipherId, attachmentId, token
|
|||
headers,
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
20
src/auth.rs
20
src/auth.rs
|
|
@ -3,14 +3,17 @@ use axum::{
|
|||
http::{header, request::Parts},
|
||||
};
|
||||
use constant_time_eq::constant_time_eq;
|
||||
use jsonwebtoken::{decode, DecodingKey, Validation};
|
||||
use jsonwebtoken::{decode, Algorithm, DecodingKey, Validation};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashSet;
|
||||
use std::sync::Arc;
|
||||
use worker::Env;
|
||||
|
||||
use crate::db;
|
||||
use crate::error::AppError;
|
||||
|
||||
pub(crate) const JWT_VALIDATION_LEEWAY_SECS: u64 = 60;
|
||||
|
||||
#[derive(Debug, Serialize, Deserialize)]
|
||||
pub struct Claims {
|
||||
pub sub: String, // User ID
|
||||
|
|
@ -25,6 +28,19 @@ pub struct Claims {
|
|||
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
|
||||
pub struct AuthUser(
|
||||
pub String, // user_id
|
||||
|
|
@ -55,7 +71,7 @@ impl FromRequestParts<Arc<Env>> for Claims {
|
|||
|
||||
// Decode and validate the token
|
||||
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()))?;
|
||||
|
||||
let claims = token_data.claims;
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ use uuid::Uuid;
|
|||
use worker::{query, wasm_bindgen::JsValue, Bucket, D1Database, Env, HttpMetadata};
|
||||
|
||||
use crate::{
|
||||
auth::Claims,
|
||||
auth::{Claims, JWT_VALIDATION_LEEWAY_SECS},
|
||||
db,
|
||||
error::AppError,
|
||||
models::{
|
||||
|
|
@ -560,7 +560,7 @@ fn download_url(
|
|||
attachment_id: &str,
|
||||
user_id: &str,
|
||||
) -> 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('/');
|
||||
Ok(format!(
|
||||
"{normalized_base}/api/ciphers/{cipher_id}/attachment/{attachment_id}/download?token={token}"
|
||||
|
|
@ -734,7 +734,7 @@ fn validate_size_within_declared(
|
|||
Ok(())
|
||||
}
|
||||
|
||||
fn build_download_token(
|
||||
fn build_upload_download_token(
|
||||
env: &Env,
|
||||
user_id: &str,
|
||||
cipher_id: &str,
|
||||
|
|
@ -744,6 +744,7 @@ fn build_download_token(
|
|||
let now = Utc::now().timestamp();
|
||||
let exp = now
|
||||
.checked_add(ttl_secs)
|
||||
.and_then(|exp| exp.checked_sub(JWT_VALIDATION_LEEWAY_SECS as i64))
|
||||
.ok_or_else(|| AppError::Internal)?;
|
||||
|
||||
if exp < 0 {
|
||||
|
|
@ -778,7 +779,7 @@ fn upload_url(
|
|||
attachment_id: &str,
|
||||
user_id: &str,
|
||||
) -> 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('/');
|
||||
Ok(format!(
|
||||
"{normalized_base}/api/ciphers/{cipher_id}/attachment/{attachment_id}/azure-upload?token={token}"
|
||||
|
|
|
|||
|
|
@ -1,14 +1,14 @@
|
|||
use axum::{extract::State, Form, Json};
|
||||
use chrono::{Duration, Utc};
|
||||
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_json::Value;
|
||||
use std::sync::Arc;
|
||||
use worker::{query, Env};
|
||||
|
||||
use crate::{
|
||||
auth::Claims,
|
||||
auth::{jwt_validation, Claims},
|
||||
crypto::{ct_eq, generate_salt, hash_password_for_storage, validate_totp},
|
||||
db,
|
||||
error::AppError,
|
||||
|
|
@ -494,7 +494,7 @@ pub async fn token(
|
|||
let token_data = decode::<RefreshClaims>(
|
||||
&refresh_token,
|
||||
&DecodingKey::from_secret(jwt_refresh_secret.as_ref()),
|
||||
&Validation::default(),
|
||||
&jwt_validation(),
|
||||
)
|
||||
.map_err(|_| AppError::Unauthorized("Invalid refresh token".to_string()))?;
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue