diff --git a/Cargo.lock b/Cargo.lock index d5d21a2..e175289 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1960,7 +1960,7 @@ dependencies = [ "k256", "lazy_static", "num-bigint", - "pem", + "pem 1.1.1", "rand", "simple_asn1", "zeroize", @@ -1974,7 +1974,7 @@ dependencies = [ "lazy_static", "num-bigint", "p256", - "pem", + "pem 1.1.1", "rand", "rand_chacha", "simple_asn1", @@ -2758,6 +2758,21 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "jsonwebtoken" +version = "9.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9ae10193d25051e74945f1ea2d0b42e03cc3b890f7e4cc5faa44997d808193f" +dependencies = [ + "base64 0.21.7", + "js-sys", + "pem 3.0.4", + "ring", + "serde", + "serde_json", + "simple_asn1", +] + [[package]] name = "k256" version = "0.13.3" @@ -3526,6 +3541,16 @@ dependencies = [ "base64 0.13.1", ] +[[package]] +name = "pem" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e459365e590736a54c3fa561947c84837534b8e9af6fc5bf781307e82658fae" +dependencies = [ + "base64 0.22.0", + "serde", +] + [[package]] name = "pem-rfc7468" version = "0.7.0" @@ -5785,6 +5810,7 @@ dependencies = [ "ic-agent", "icondata", "icondata_core", + "jsonwebtoken", "k256", "leptos", "leptos_axum", @@ -5793,6 +5819,7 @@ dependencies = [ "leptos_router", "log", "openidconnect", + "rand", "rand_core", "serde", "serde_json", diff --git a/client/src/lib.rs b/client/src/lib.rs index 5a4c11d..cce5da7 100644 --- a/client/src/lib.rs +++ b/client/src/lib.rs @@ -7,7 +7,7 @@ use consts::DEFAULT_AUTH_URL; pub use error::*; use ic_agent::{export::Principal, Identity}; use reqs::{EmptyReq, GetUserMetadataReqW, SetUserMetadataReqW, UpgradeRefreshClaimReq}; -use reqwest::Url; +use reqwest::{header::{self, HeaderMap}, Url}; use serde::{de::DeserializeOwned, Serialize}; use types::{ metadata::{ @@ -34,10 +34,14 @@ impl Default for AuthClient { } impl AuthClient { - pub fn with_base_url(base_url: Url) -> Self { + pub fn with_base_url(base_url: Url, opt_token: Option<&str>) -> Self { + let mut headers = HeaderMap::new(); + if let Some(token) = opt_token { + headers.insert(header::AUTHORIZATION, format!("Bearer {token}").parse().unwrap()); + } Self { base_url, - client: Default::default(), + client: reqwest::Client::builder().default_headers(headers).build().unwrap(), } } diff --git a/ssr/Cargo.toml b/ssr/Cargo.toml index 173d588..987dc40 100644 --- a/ssr/Cargo.toml +++ b/ssr/Cargo.toml @@ -39,6 +39,8 @@ icondata = "0.3.0" icondata_core = "0.1.0" url = "2.5.0" hmac = { version = "0.12.1", optional = true } +rand = { version = "0.8.5", features = ["std_rng"] } +jsonwebtoken = "9.3.0" [features] hydrate = ["leptos/hydrate", "leptos_meta/hydrate", "leptos_router/hydrate", "ic-agent/wasm-bindgen"] diff --git a/ssr/src/api/mod.rs b/ssr/src/api/mod.rs index cefd86c..06727a1 100644 --- a/ssr/src/api/mod.rs +++ b/ssr/src/api/mod.rs @@ -4,17 +4,26 @@ use types::{ DelegatedIdentityWire, SignedRefreshTokenClaim, }; +use crate::consts::NAMESPACE; + +#[cfg(feature = "ssr")] +use crate::extractors::JwtAuth; + #[cfg(feature = "ssr")] pub mod server_impl; +#[cfg(feature = "ssr")] +use leptos_axum::extract; + #[server(endpoint = "extract_or_generate", input = Json, output = Json)] pub async fn extract_or_generate_identity() -> Result { - server_impl::extract_or_generate_identity_impl().await + let jwt_auth = extract::().await?; + server_impl::extract_or_generate_identity_impl(jwt_auth.namespace).await } #[server(endpoint = "logout", input = Json, output = Json)] pub async fn logout_identity() -> Result { - server_impl::logout_identity_impl().await + server_impl::logout_identity_impl(NAMESPACE.into()).await } #[server(endpoint = "upgrade_refresh_claim", input = Json, output = Json)] diff --git a/ssr/src/api/server_impl/google.rs b/ssr/src/api/server_impl/google.rs index 8336d63..2c8097f 100644 --- a/ssr/src/api/server_impl/google.rs +++ b/ssr/src/api/server_impl/google.rs @@ -138,6 +138,7 @@ pub async fn perform_google_auth_impl( .ok_or_else(|| ServerFnError::new("Attempting google login without a temp identity"))?; let temp_id: TempIdentity = serde_json::from_str(temp_id_cookie.value())?; let principal = temp_id.principal; + let namespace = temp_id.namespace.clone(); let host = temp_id.referrer_host.clone(); temp_id.validate()?; @@ -147,7 +148,7 @@ pub async fn perform_google_auth_impl( } else { associate_principal_with_google_sub(&kv, principal, sub_id).await? }; - let claim = refresh_claim(principal, host); + let claim = refresh_claim(principal, host, namespace); let s_claim = sign_refresh_claim(claim, &key)?; Ok(s_claim) diff --git a/ssr/src/api/server_impl/mod.rs b/ssr/src/api/server_impl/mod.rs index afed13b..740125c 100644 --- a/ssr/src/api/server_impl/mod.rs +++ b/ssr/src/api/server_impl/mod.rs @@ -12,7 +12,8 @@ use ic_agent::{ export::Principal, identity::{Delegation, Identity, Secp256k1Identity, SignedDelegation}, }; -use k256::sha2::Sha256; +use k256::{elliptic_curve::SecretKey, sha2::{Digest, Sha256}, Secp256k1}; +use rand::{rngs::StdRng, SeedableRng}; use leptos::{expect_context, ServerFnError}; use leptos_axum::{extract, extract_with_state, ResponseOptions}; use rand_core::OsRng; @@ -59,6 +60,7 @@ pub fn delegate_identity(from: &impl Identity) -> DelegatedIdentityWire { #[derive(Serialize, Deserialize, Clone)] pub struct TempIdentity { pub principal: Principal, + pub namespace: String, pub signature: Signature, pub referrer_host: url::Host, } @@ -86,11 +88,12 @@ pub fn set_cookies(resp: &ResponseOptions, jar: impl IntoResponse) { } #[cfg(feature = "oauth")] -fn refresh_claim(principal: Principal, referrer_host: url::Host) -> types::RefreshTokenClaim { +fn refresh_claim(principal: Principal, referrer_host: url::Host, namespace: String) -> types::RefreshTokenClaim { use types::REFRESH_TOKEN_CLAIM_MAX_AGE; types::RefreshTokenClaim { principal, + namespace, expiry_epoch: current_epoch() + REFRESH_TOKEN_CLAIM_MAX_AGE, referrer_host, } @@ -134,9 +137,10 @@ fn verify_refresh_claim( Ok(claim.principal) } -#[derive(Clone, Copy, Deserialize, Serialize)] +#[derive(Clone, Deserialize, Serialize)] struct RefreshToken { principal: Principal, + namespace: String, expiry_epoch_ms: u128, } @@ -153,7 +157,7 @@ async fn extract_principal_from_cookie( Ok(Some(token.principal)) } -async fn fetch_identity_from_kv( +async fn fetch_identity_key_from_kv( kv: &KVStoreImpl, principal: Principal, ) -> Result, ServerFnError> { @@ -171,27 +175,60 @@ pub async fn try_extract_identity( let Some(principal) = extract_principal_from_cookie(jar).await? else { return Ok(None); }; - fetch_identity_from_kv(kv, principal).await + fetch_identity_key_from_kv(kv, principal).await } -async fn generate_and_save_identity(kv: &KVStoreImpl) -> Result { +async fn generate_and_save_identity_key(kv: &KVStoreImpl) -> Result, ServerFnError> { let base_identity_key = k256::SecretKey::random(&mut OsRng); - let base_identity = Secp256k1Identity::from_private_key(base_identity_key.clone()); - let principal = base_identity.sender().unwrap(); + save_identity_in_kv(kv, base_identity_key.clone()).await?; + Ok(base_identity_key) +} + +async fn save_identity_in_kv(kv: &KVStoreImpl, identity_key: SecretKey) -> Result<(), ServerFnError> { + + let identity = Secp256k1Identity::from_private_key(identity_key.clone()); + let principal = identity.sender().unwrap(); + let jwk = identity_key.to_jwk_string(); + kv.write(principal.to_text(), jwk.to_string()).await?; + Ok(()) +} + +fn generate_namespaced_identity_key(namespace: &str, from_secret_key: SecretKey) -> SecretKey{ + + if namespace.to_uppercase().eq("YRAL") { + return from_secret_key; + } + + let app_name = namespace.as_bytes(); - let base_jwk = base_identity_key.to_jwk_string(); - kv.write(principal.to_text(), base_jwk.to_string()).await?; - Ok(base_identity) + let mut combined_bytes:Vec = Vec::new(); + combined_bytes.extend_from_slice(&from_secret_key.to_bytes()); + combined_bytes.extend_from_slice(app_name); + + let mut hasher = Sha256::new(); + hasher.update(combined_bytes); + let hashed_val = hasher.finalize(); + + let mut seed = [0u8; 32]; + seed.copy_from_slice(&hashed_val[..32]); + + k256::SecretKey::random(&mut StdRng::from_seed(seed)) } -pub async fn update_user_identity( + + +pub async fn set_cookie_and_get_namespaced_identity( response_opts: &ResponseOptions, mut jar: SignedCookieJar, - identity: impl Identity, + identity_key: SecretKey, + namespace: &str ) -> Result { let refresh_max_age = REFRESH_MAX_AGE; + let identity = Secp256k1Identity::from_private_key(identity_key.clone()); + let principal = identity.sender().unwrap(); let refresh_token = RefreshToken { - principal: identity.sender().unwrap(), + principal, + namespace: namespace.to_owned(), expiry_epoch_ms: (current_epoch() + refresh_max_age).as_millis(), }; let refresh_token_enc = serde_json::to_string(&refresh_token)?; @@ -207,34 +244,37 @@ pub async fn update_user_identity( jar = jar.add(refresh_cookie); set_cookies(response_opts, jar); - Ok(delegate_identity(&identity)) + let namespaced_identity_key = generate_namespaced_identity_key(&namespace, identity_key); + let namespaced_identity = Secp256k1Identity::from_private_key(namespaced_identity_key); + + Ok(delegate_identity(&namespaced_identity)) } -pub async fn extract_or_generate_identity_impl() -> Result { +pub async fn extract_or_generate_identity_impl(namespace: String) -> Result { let key: Key = expect_context(); let jar: SignedCookieJar = extract_with_state(&key).await?; let kv: KVStoreImpl = expect_context(); - let base_identity = if let Some(identity) = try_extract_identity(&jar, &kv).await? { - Secp256k1Identity::from_private_key(identity) + let base_identity_key = if let Some(identity) = try_extract_identity(&jar, &kv).await? { + identity } else { - generate_and_save_identity(&kv).await? + generate_and_save_identity_key(&kv).await? }; let resp: ResponseOptions = expect_context(); - let delegated = update_user_identity(&resp, jar, base_identity).await?; + let delegated = set_cookie_and_get_namespaced_identity(&resp, jar, base_identity_key, &namespace).await?; Ok(delegated) } -pub async fn logout_identity_impl() -> Result { +pub async fn logout_identity_impl(namespace: String) -> Result { let key: Key = expect_context(); let kv: KVStoreImpl = expect_context(); let jar: SignedCookieJar = extract_with_state(&key).await?; - let base_identity = generate_and_save_identity(&kv).await?; + let base_identity_key = generate_and_save_identity_key(&kv).await?; let resp: ResponseOptions = expect_context(); - let delegated = update_user_identity(&resp, jar, base_identity).await?; + let delegated = set_cookie_and_get_namespaced_identity(&resp, jar, base_identity_key, &namespace).await?; Ok(delegated) } @@ -257,13 +297,16 @@ pub async fn upgrade_refresh_claim_impl( .ok_or_else(|| ServerFnError::new("No referrer host"))? .to_owned(); - let principal = verify_refresh_claim(s_claim, host, &key)?; - let sk = fetch_identity_from_kv(&kv, principal) + let principal = verify_refresh_claim(s_claim.clone(), host, &key)?; + let sk = fetch_identity_key_from_kv(&kv, principal) .await? .ok_or_else(|| ServerFnError::new("No identity found"))?; + + let namespace = s_claim.claim.namespace.clone(); + let delegated = - update_user_identity(&resp, jar, Secp256k1Identity::from_private_key(sk)).await?; + set_cookie_and_get_namespaced_identity(&resp, jar, sk, &namespace).await?; Ok(delegated) } diff --git a/ssr/src/consts.rs b/ssr/src/consts.rs index 95ffea5..5fdcbf4 100644 --- a/ssr/src/consts.rs +++ b/ssr/src/consts.rs @@ -6,6 +6,7 @@ pub const DELEGATION_MAX_AGE: Duration = Duration::from_secs(60 * 60 * 24 * 7); pub const REFRESH_MAX_AGE: Duration = Duration::from_secs(60 * 60 * 24 * 30); pub const REFRESH_TOKEN_COOKIE: &str = "user-identity"; pub const TEMP_IDENTITY_COOKIE: &str = "temp-identity"; +pub const NAMESPACE: &str = "YRAL"; #[cfg(all(feature = "ssr", feature = "oauth-google"))] pub mod google { diff --git a/ssr/src/extractors.rs b/ssr/src/extractors.rs new file mode 100644 index 0000000..bb9ce8e --- /dev/null +++ b/ssr/src/extractors.rs @@ -0,0 +1,39 @@ +use std::env; + +use axum::{async_trait, extract::FromRequestParts}; +use http::{header, request::Parts}; +use jsonwebtoken::{decode, DecodingKey, Validation}; +use serde::{Deserialize, Serialize}; + +#[derive(Serialize, Deserialize)] +pub struct JwtAuth { + pub namespace: String, +} + +pub fn decode_jwt_token(token: &str) -> Result{ + let pub_identity = env::var("YRAL_PUBLIC_KEY").expect("Yral public key should be present"); + let decoding_key = DecodingKey::from_ec_pem(pub_identity.as_bytes()).map_err(|e| e.to_string())?; + + let token_data = decode::(token, &decoding_key, &Validation::new(jsonwebtoken::Algorithm::ES256)); + match token_data { + Ok(data) => Ok(data.claims), + Err(e) => Err(e.to_string()) + } +} + + + +#[async_trait] +impl FromRequestParts<()> for JwtAuth { + type Rejection = String; + + async fn from_request_parts(parts: &mut Parts, _: &()) -> Result{ + let access_token = parts.headers.get(header::AUTHORIZATION).and_then(|val| val.to_str().ok()).and_then(|str| str.split(" ").nth(1)); + match access_token { + Some(token) => { + decode_jwt_token(token) + }, + None => Err("Unauthorized".into()) + } + } +} \ No newline at end of file diff --git a/ssr/src/lib.rs b/ssr/src/lib.rs index 2749969..3713c4d 100644 --- a/ssr/src/lib.rs +++ b/ssr/src/lib.rs @@ -5,6 +5,9 @@ pub mod consts; pub mod error_template; #[cfg(feature = "ssr")] pub mod fileserv; + +#[cfg(feature = "ssr")] +pub mod extractors; pub mod page; pub mod state; diff --git a/ssr/src/page/root.rs b/ssr/src/page/root.rs index 1925bae..19c9581 100644 --- a/ssr/src/page/root.rs +++ b/ssr/src/page/root.rs @@ -3,6 +3,8 @@ use leptos::*; use leptos_router::*; use serde::{Deserialize, Serialize}; +use crate::consts::NAMESPACE; + #[server] async fn prepare_cookies(params: RootParams) -> Result<(), ServerFnError> { use crate::api::server_impl::{set_cookies, TempIdentity}; @@ -18,6 +20,7 @@ async fn prepare_cookies(params: RootParams) -> Result<(), ServerFnError> { use yral_identity::Signature; let sig: Signature = serde_json::from_str(¶ms.signature_json)?; + let namespace = NAMESPACE.into(); let headers: HeaderMap = extract().await?; let referrer_raw = headers @@ -34,6 +37,7 @@ async fn prepare_cookies(params: RootParams) -> Result<(), ServerFnError> { principal: params.principal, signature: sig, referrer_host, + namespace }; let temp_id_raw = serde_json::to_string(&temp_id)?; let temp_id_cookie = Cookie::build((TEMP_IDENTITY_COOKIE, temp_id_raw)) @@ -55,7 +59,7 @@ async fn prepare_cookies(params: RootParams) -> Result<(), ServerFnError> { struct RootParams { principal: Principal, /// Signature over [types::LoginIntent] - signature_json: String, + signature_json: String } #[component] diff --git a/types/src/lib.rs b/types/src/lib.rs index 6ae4b3b..f429c50 100644 --- a/types/src/lib.rs +++ b/types/src/lib.rs @@ -60,6 +60,7 @@ impl From for Message { pub struct RefreshTokenClaim { pub principal: Principal, pub expiry_epoch: Duration, + pub namespace: String, pub referrer_host: Host, }