diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6d8b036f..8180094c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -84,16 +84,26 @@ jobs: -p cashu --no-default-features --features wallet, -p cashu --no-default-features --features mint, -p cashu --no-default-features --features "mint swagger", + -p cashu --no-default-features --features auth, + -p cashu --no-default-features --features "mint auth", + -p cashu --no-default-features --features "wallet auth", -p cdk-common, -p cdk-common --no-default-features, -p cdk-common --no-default-features --features wallet, -p cdk-common --no-default-features --features mint, -p cdk-common --no-default-features --features "mint swagger", + -p cdk-common --no-default-features --features "auth", + -p cdk-common --no-default-features --features "mint auth", + -p cdk-common --no-default-features --features "wallet auth", -p cdk, -p cdk --no-default-features, -p cdk --no-default-features --features wallet, -p cdk --no-default-features --features mint, -p cdk --no-default-features --features "mint swagger", + -p cdk --no-default-features --features auth, + -p cdk --features auth, + -p cdk --no-default-features --features "auth mint", + -p cdk --no-default-features --features "auth wallet", -p cdk-redb, -p cdk-sqlite, -p cdk-sqlite --features sqlcipher, @@ -101,6 +111,7 @@ jobs: -p cdk-axum --no-default-features --features swagger, -p cdk-axum --no-default-features --features redis, -p cdk-axum --no-default-features --features "redis swagger", + -p cdk-axum --no-default-features --features "auth redis", -p cdk-axum, -p cdk-cln, -p cdk-lnd, @@ -126,6 +137,8 @@ jobs: --bin cdk-mintd --no-default-features --features "swagger lnd", --bin cdk-mintd --no-default-features --features "swagger cln", --bin cdk-mintd --no-default-features --features "swagger lnbits", + --bin cdk-mintd --no-default-features --features "auth lnd", + --bin cdk-mintd --no-default-features --features "auth cln", --bin cdk-mint-cli, ] steps: @@ -142,11 +155,11 @@ jobs: - name: Test run: nix develop -i -L .#stable --command cargo test ${{ matrix.build-args }} - itest: + regtest-itest: name: "Integration regtest tests" runs-on: ubuntu-latest timeout-minutes: 15 - needs: [pre-commit-checks, clippy, pure-itest, fake-wallet-itest] + needs: [pre-commit-checks, clippy, pure-itest, fake-mint-itest] strategy: matrix: build-args: @@ -167,13 +180,11 @@ jobs: uses: DeterminateSystems/magic-nix-cache-action@v6 - name: Rust Cache uses: Swatinem/rust-cache@v2 - - name: Clippy - run: nix develop -i -L .#stable --command cargo clippy ${{ matrix.build-args }} -- -D warnings - name: Test run: nix develop -i -L .#stable --command just itest ${{ matrix.database }} - fake-wallet-itest: - name: "Integration fake wallet tests" + fake-mint-itest: + name: "Integration fake mint tests" runs-on: ubuntu-latest timeout-minutes: 15 needs: [pre-commit-checks, clippy] @@ -198,8 +209,8 @@ jobs: - name: Rust Cache uses: Swatinem/rust-cache@v2 - name: Clippy - run: nix develop -i -L .#stable --command cargo clippy ${{ matrix.build-args }} -- -D warnings - - name: Test fake mint + run: nix develop -i -L .#stable --command cargo clippy -- -D warnings + - name: Test fake auth mint run: nix develop -i -L .#stable --command just fake-mint-itest ${{ matrix.database }} pure-itest: @@ -224,7 +235,7 @@ jobs: name: "Payment processor tests" runs-on: ubuntu-latest timeout-minutes: 15 - needs: [pre-commit-checks, clippy, pure-itest, fake-wallet-itest, itest] + needs: [pre-commit-checks, clippy, pure-itest, fake-mint-itest, regtest-itest] strategy: matrix: ln: @@ -256,7 +267,10 @@ jobs: [ -p cashu --no-default-features --features "wallet mint", -p cdk-common --no-default-features --features "wallet mint", - -p cdk --no-default-features --features "mint mint", + -p cdk, + -p cdk --no-default-features --features "mint auth", + -p cdk --no-default-features --features "wallet auth", + -p cdk --no-default-features --features "http_subscription", -p cdk-axum, -p cdk-axum --no-default-features --features redis, -p cdk-lnbits, @@ -339,3 +353,39 @@ jobs: uses: Swatinem/rust-cache@v2 - name: Build cdk wasm run: nix develop -i -L ".#${{ matrix.rust }}" --command cargo build ${{ matrix.build-args }} --target ${{ matrix.target }} + + fake-mint-auth-itest: + name: "Integration fake mint auth tests" + runs-on: ubuntu-latest + timeout-minutes: 15 + needs: [pre-commit-checks, clippy, pure-itest, fake-mint-itest] + strategy: + matrix: + database: + [ + REDB, + SQLITE, + ] + steps: + - name: checkout + uses: actions/checkout@v4 + - name: Install Nix + uses: DeterminateSystems/nix-installer-action@v11 + - name: Nix Cache + uses: DeterminateSystems/magic-nix-cache-action@v6 + - name: Rust Cache + uses: Swatinem/rust-cache@v2 + - name: Start Keycloak with Backup + run: | + docker compose -f misc/keycloak/docker-compose-recover.yml up -d + until docker logs $(docker ps -q --filter "ancestor=quay.io/keycloak/keycloak:25.0.6") | grep "Keycloak 25.0.6 on JVM (powered by Quarkus 3.8.5) started"; do sleep 1; done + + - name: Verify Keycloak Import + run: | + docker logs $(docker ps -q --filter "ancestor=quay.io/keycloak/keycloak:25.0.6") | grep "Imported" + - name: Test fake auth mint + run: nix develop -i -L .#stable --command just fake-auth-mint-itest ${{ matrix.database }} http://127.0.0.1:8080/realms/cdk-test-realm/.well-known/openid-configuration + - name: Stop and clean up Docker Compose + run: | + docker compose -f misc/keycloak/docker-compose-recover.yml down + diff --git a/.gitignore b/.gitignore index fa01a359..4790adc3 100644 --- a/.gitignore +++ b/.gitignore @@ -10,3 +10,5 @@ config.toml result Cargo.lock .aider* +**/postgres_data/ +**/.env diff --git a/.typos.toml b/.typos.toml index dce32dbb..e3cc2980 100644 --- a/.typos.toml +++ b/.typos.toml @@ -3,5 +3,8 @@ extend-ignore-re = [ # Ignore cashu tokens "cashuA[A-Za-z0-9-_]+", "cashuB[A-Za-z0-9-_]+", - "casshuAeyJ0b2tlbiI6W3sibWludCI6Imh0dHBzOi8vODMzMy5zcGFjZTozMzM4IiwicHJvb2ZzIjpbeyJhbW91bnQiOjIsImlkIjoiMDA5YTFmMjkzMjUzZTQxZSIsInNlY3JldCI6IjQwNzkxNWJjMjEyYmU2MWE3N2UzZTZkMmFlYjRjNzI3OTgwYmRhNTFjZDA2YTZhZmMyOWUyODYxNzY4YTc4MzciLCJDIjoiMDJiYzkwOTc5OTdkODFhZmIyY2M3MzQ2YjVlNDM0NWE5MzQ2YmQyYTUwNmViNzk1ODU5OGE3MmYwY2Y4NTE2M2VhIn0seyJhbW91bnQiOjgsImlkIjoiMDA5YTFmMjkzMjUzZTQxZSIsInNlY3JldCI6ImZlMTUxMDkzMTRlNjFkNzc1NmIwZjhlZTBmMjNhNjI0YWNhYTNmNGUwNDJmNjE0MzNjNzI4YzcwNTdiOTMxYmUiLCJDIjoiMDI5ZThlNTA1MGI4OTBhN2Q2YzA5NjhkYjE2YmMxZDVkNWZhMDQwZWExZGUyODRmNmVjNjlkNjEyOTlmNjcxMDU5In1dfV0sInVuaXQiOiJzYXQiLCJtZW1vIjoiVGhhbmsgeW91LiJ9" + "casshuAeyJ0b2tlbiI6W3sibWludCI6Imh0dHBzOi8vODMzMy5zcGFjZTozMzM4IiwicHJvb2ZzIjpbeyJhbW91bnQiOjIsImlkIjoiMDA5YTFmMjkzMjUzZTQxZSIsInNlY3JldCI6IjQwNzkxNWJjMjEyYmU2MWE3N2UzZTZkMmFlYjRjNzI3OTgwYmRhNTFjZDA2YTZhZmMyOWUyODYxNzY4YTc4MzciLCJDIjoiMDJiYzkwOTc5OTdkODFhZmIyY2M3MzQ2YjVlNDM0NWE5MzQ2YmQyYTUwNmViNzk1ODU5OGE3MmYwY2Y4NTE2M2VhIn0seyJhbW91bnQiOjgsImlkIjoiMDA5YTFmMjkzMjUzZTQxZSIsInNlY3JldCI6ImZlMTUxMDkzMTRlNjFkNzc1NmIwZjhlZTBmMjNhNjI0YWNhYTNmNGUwNDJmNjE0MzNjNzI4YzcwNTdiOTMxYmUiLCJDIjoiMDI5ZThlNTA1MGI4OTBhN2Q2YzA5NjhkYjE2YmMxZDVkNWZhMDQwZWExZGUyODRmNmVjNjlkNjEyOTlmNjcxMDU5In1dfV0sInVuaXQiOiJzYXQiLCJtZW1vIjoiVGhhbmsgeW91LiJ9", + "autheticator", + "Gam", + "flate2" ] diff --git a/Cargo.toml b/Cargo.toml index b03668bb..cfa003d7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -17,6 +17,7 @@ async-trait = "0.1" axum = { version = "0.8.1", features = ["ws"] } bitcoin = { version = "0.32.2", features = ["base64", "serde", "rand", "rand-std"] } bip39 = { version = "2.0", features = ["rand"] } +jsonwebtoken = "9.2.0" cashu = { path = "./crates/cashu", version = "=0.7.1" } cdk = { path = "./crates/cdk", default-features = false, version = "=0.7.2" } cdk-common = { path = "./crates/cdk-common", default-features = false, version = "=0.7.1" } @@ -64,6 +65,7 @@ reqwest = { version = "0.12", default-features = false, features = [ once_cell = "1.20.2" instant = { version = "0.1", default-features = false } rand = "0.8.5" +regex = "1" home = "0.5.5" tonic = { version = "0.12.3", features = [ "channel", @@ -72,6 +74,8 @@ tonic = { version = "0.12.3", features = [ ] } prost = "0.13.1" tonic-build = "0.12" +strum = "0.27.1" +strum_macros = "0.27.1" diff --git a/crates/cashu/Cargo.toml b/crates/cashu/Cargo.toml index 1930192b..f6856aa1 100644 --- a/crates/cashu/Cargo.toml +++ b/crates/cashu/Cargo.toml @@ -10,10 +10,11 @@ rust-version = "1.75.0" # MSRV license.workspace = true [features] -default = ["mint", "wallet"] +default = ["mint", "wallet", "auth"] swagger = ["dep:utoipa"] mint = ["dep:uuid"] wallet = [] +auth = ["dep:strum", "dep:strum_macros", "dep:regex"] bench = [] [dependencies] @@ -30,6 +31,9 @@ url.workspace = true utoipa = { workspace = true, optional = true } serde_json.workspace = true serde_with.workspace = true +regex = { workspace = true, optional = true } +strum = { workspace = true, optional = true } +strum_macros = { workspace = true, optional = true } [target.'cfg(target_arch = "wasm32")'.dependencies] instant = { workspace = true, features = ["wasm-bindgen", "inaccurate"] } diff --git a/crates/cashu/src/nuts/auth/mod.rs b/crates/cashu/src/nuts/auth/mod.rs new file mode 100644 index 00000000..690a4e5f --- /dev/null +++ b/crates/cashu/src/nuts/auth/mod.rs @@ -0,0 +1,8 @@ +pub mod nut21; +pub mod nut22; + +pub use nut21::{Method, ProtectedEndpoint, RoutePath, Settings as ClearAuthSettings}; +pub use nut22::{ + AuthProof, AuthRequired, AuthToken, BlindAuthToken, MintAuthRequest, + Settings as BlindAuthSettings, +}; diff --git a/crates/cashu/src/nuts/auth/nut21.rs b/crates/cashu/src/nuts/auth/nut21.rs new file mode 100644 index 00000000..02c69af7 --- /dev/null +++ b/crates/cashu/src/nuts/auth/nut21.rs @@ -0,0 +1,409 @@ +//! 21 Clear Auth + +use std::collections::HashSet; +use std::str::FromStr; + +use regex::Regex; +use serde::{Deserialize, Serialize}; +use strum::IntoEnumIterator; +use strum_macros::EnumIter; +use thiserror::Error; + +/// NUT21 Error +#[derive(Debug, Error)] +pub enum Error { + /// Invalid regex pattern + #[error("Invalid regex pattern: {0}")] + InvalidRegex(#[from] regex::Error), +} + +/// Clear Auth Settings +#[derive(Debug, Clone, PartialEq, Eq, Hash, Default, Serialize)] +#[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))] +pub struct Settings { + /// Openid discovery + pub openid_discovery: String, + /// Client ID + pub client_id: String, + /// Protected endpoints + pub protected_endpoints: Vec, +} + +impl Settings { + /// Create new [`Settings`] + pub fn new( + openid_discovery: String, + client_id: String, + protected_endpoints: Vec, + ) -> Self { + Self { + openid_discovery, + client_id, + protected_endpoints, + } + } +} + +// Custom deserializer for Settings to expand regex patterns in protected endpoints +impl<'de> Deserialize<'de> for Settings { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + // Define a temporary struct to deserialize the raw data + #[derive(Deserialize)] + struct RawSettings { + openid_discovery: String, + client_id: String, + protected_endpoints: Vec, + } + + #[derive(Deserialize)] + struct RawProtectedEndpoint { + method: Method, + path: String, + } + + // Deserialize into the temporary struct + let raw = RawSettings::deserialize(deserializer)?; + + // Process protected endpoints, expanding regex patterns if present + let mut protected_endpoints = HashSet::new(); + + for raw_endpoint in raw.protected_endpoints { + let expanded_paths = matching_route_paths(&raw_endpoint.path).map_err(|e| { + serde::de::Error::custom(format!( + "Invalid regex pattern '{}': {}", + raw_endpoint.path, e + )) + })?; + + for path in expanded_paths { + protected_endpoints.insert(ProtectedEndpoint::new(raw_endpoint.method, path)); + } + } + + // Create the final Settings struct + Ok(Settings { + openid_discovery: raw.openid_discovery, + client_id: raw.client_id, + protected_endpoints: protected_endpoints.into_iter().collect(), + }) + } +} + +/// List of the methods and paths that are protected +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))] +pub struct ProtectedEndpoint { + /// HTTP Method + pub method: Method, + /// Route path + pub path: RoutePath, +} + +impl ProtectedEndpoint { + /// Create [`CachedEndpoint`] + pub fn new(method: Method, path: RoutePath) -> Self { + Self { method, path } + } +} + +/// HTTP method +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +#[serde(rename_all = "UPPERCASE")] +#[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))] +pub enum Method { + /// Get + Get, + /// POST + Post, +} + +/// Route path +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, EnumIter)] +#[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))] +#[serde(rename_all = "snake_case")] +pub enum RoutePath { + /// Bolt11 Mint Quote + #[serde(rename = "/v1/mint/quote/bolt11")] + MintQuoteBolt11, + /// Bolt11 Mint + #[serde(rename = "/v1/mint/bolt11")] + MintBolt11, + /// Bolt11 Melt Quote + #[serde(rename = "/v1/melt/quote/bolt11")] + MeltQuoteBolt11, + /// Bolt11 Melt + #[serde(rename = "/v1/melt/bolt11")] + MeltBolt11, + /// Swap + #[serde(rename = "/v1/swap")] + Swap, + /// Checkstate + #[serde(rename = "/v1/checkstate")] + Checkstate, + /// Restore + #[serde(rename = "/v1/restore")] + Restore, + /// Mint Blind Auth + #[serde(rename = "/v1/auth/blind/mint")] + MintBlindAuth, +} + +pub fn matching_route_paths(pattern: &str) -> Result, Error> { + let regex = Regex::from_str(pattern)?; + + Ok(RoutePath::iter() + .filter(|path| regex.is_match(&path.to_string())) + .collect()) +} + +impl std::fmt::Display for RoutePath { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + // Use serde to serialize to a JSON string, then extract the value without quotes + let json_str = match serde_json::to_string(self) { + Ok(s) => s, + Err(_) => return write!(f, ""), + }; + // Remove the quotes from the JSON string + let path = json_str.trim_matches('"'); + write!(f, "{}", path) + } +} + +#[cfg(test)] +mod tests { + + use super::*; + + #[test] + fn test_matching_route_paths_all() { + // Regex that matches all paths + let paths = matching_route_paths(".*").unwrap(); + + // Should match all variants + assert_eq!(paths.len(), RoutePath::iter().count()); + + // Verify all variants are included + assert!(paths.contains(&RoutePath::MintQuoteBolt11)); + assert!(paths.contains(&RoutePath::MintBolt11)); + assert!(paths.contains(&RoutePath::MeltQuoteBolt11)); + assert!(paths.contains(&RoutePath::MeltBolt11)); + assert!(paths.contains(&RoutePath::Swap)); + assert!(paths.contains(&RoutePath::Checkstate)); + assert!(paths.contains(&RoutePath::Restore)); + assert!(paths.contains(&RoutePath::MintBlindAuth)); + } + + #[test] + fn test_matching_route_paths_mint_only() { + // Regex that matches only mint paths + let paths = matching_route_paths("^/v1/mint/.*").unwrap(); + + // Should match only mint paths + assert_eq!(paths.len(), 2); + assert!(paths.contains(&RoutePath::MintQuoteBolt11)); + assert!(paths.contains(&RoutePath::MintBolt11)); + + // Should not match other paths + assert!(!paths.contains(&RoutePath::MeltQuoteBolt11)); + assert!(!paths.contains(&RoutePath::MeltBolt11)); + assert!(!paths.contains(&RoutePath::Swap)); + } + + #[test] + fn test_matching_route_paths_quote_only() { + // Regex that matches only quote paths + let paths = matching_route_paths(".*/quote/.*").unwrap(); + + // Should match only quote paths + assert_eq!(paths.len(), 2); + assert!(paths.contains(&RoutePath::MintQuoteBolt11)); + assert!(paths.contains(&RoutePath::MeltQuoteBolt11)); + + // Should not match non-quote paths + assert!(!paths.contains(&RoutePath::MintBolt11)); + assert!(!paths.contains(&RoutePath::MeltBolt11)); + } + + #[test] + fn test_matching_route_paths_no_match() { + // Regex that matches nothing + let paths = matching_route_paths("/nonexistent/path").unwrap(); + + // Should match nothing + assert!(paths.is_empty()); + } + + #[test] + fn test_matching_route_paths_quote_bolt11_only() { + // Regex that matches only quote paths + let paths = matching_route_paths("/v1/mint/quote/bolt11").unwrap(); + + // Should match only quote paths + assert_eq!(paths.len(), 1); + assert!(paths.contains(&RoutePath::MintQuoteBolt11)); + } + + #[test] + fn test_matching_route_paths_invalid_regex() { + // Invalid regex pattern + let result = matching_route_paths("(unclosed parenthesis"); + + // Should return an error for invalid regex + assert!(result.is_err()); + assert!(matches!(result.unwrap_err(), Error::InvalidRegex(_))); + } + + #[test] + fn test_route_path_to_string() { + // Test that to_string() returns the correct path strings + assert_eq!( + RoutePath::MintQuoteBolt11.to_string(), + "/v1/mint/quote/bolt11" + ); + assert_eq!(RoutePath::MintBolt11.to_string(), "/v1/mint/bolt11"); + assert_eq!( + RoutePath::MeltQuoteBolt11.to_string(), + "/v1/melt/quote/bolt11" + ); + assert_eq!(RoutePath::MeltBolt11.to_string(), "/v1/melt/bolt11"); + assert_eq!(RoutePath::Swap.to_string(), "/v1/swap"); + assert_eq!(RoutePath::Checkstate.to_string(), "/v1/checkstate"); + assert_eq!(RoutePath::Restore.to_string(), "/v1/restore"); + assert_eq!(RoutePath::MintBlindAuth.to_string(), "/v1/auth/blind/mint"); + } + + #[test] + fn test_settings_deserialize_direct_paths() { + let json = r#"{ + "openid_discovery": "https://example.com/.well-known/openid-configuration", + "client_id": "client123", + "protected_endpoints": [ + { + "method": "GET", + "path": "/v1/mint/bolt11" + }, + { + "method": "POST", + "path": "/v1/swap" + } + ] + }"#; + + let settings: Settings = serde_json::from_str(json).unwrap(); + + assert_eq!( + settings.openid_discovery, + "https://example.com/.well-known/openid-configuration" + ); + assert_eq!(settings.client_id, "client123"); + assert_eq!(settings.protected_endpoints.len(), 2); + + // Check that both paths are included + let paths = settings + .protected_endpoints + .iter() + .map(|ep| (ep.method, ep.path)) + .collect::>(); + assert!(paths.contains(&(Method::Get, RoutePath::MintBolt11))); + assert!(paths.contains(&(Method::Post, RoutePath::Swap))); + } + + #[test] + fn test_settings_deserialize_with_regex() { + let json = r#"{ + "openid_discovery": "https://example.com/.well-known/openid-configuration", + "client_id": "client123", + "protected_endpoints": [ + { + "method": "GET", + "path": "^/v1/mint/.*" + }, + { + "method": "POST", + "path": "/v1/swap" + } + ] + }"#; + + let settings: Settings = serde_json::from_str(json).unwrap(); + + assert_eq!( + settings.openid_discovery, + "https://example.com/.well-known/openid-configuration" + ); + assert_eq!(settings.client_id, "client123"); + assert_eq!(settings.protected_endpoints.len(), 3); // 2 mint paths + 1 swap path + + let expected_protected: HashSet = HashSet::from_iter(vec![ + ProtectedEndpoint::new(Method::Post, RoutePath::Swap), + ProtectedEndpoint::new(Method::Get, RoutePath::MintBolt11), + ProtectedEndpoint::new(Method::Get, RoutePath::MintQuoteBolt11), + ]); + + let deserlized_protected = settings.protected_endpoints.into_iter().collect(); + + assert_eq!(expected_protected, deserlized_protected); + } + + #[test] + fn test_settings_deserialize_invalid_regex() { + let json = r#"{ + "openid_discovery": "https://example.com/.well-known/openid-configuration", + "client_id": "client123", + "protected_endpoints": [ + { + "method": "GET", + "path": "(unclosed parenthesis" + } + ] + }"#; + + let result = serde_json::from_str::(json); + assert!(result.is_err()); + } + + #[test] + fn test_settings_deserialize_exact_path_match() { + let json = r#"{ + "openid_discovery": "https://example.com/.well-known/openid-configuration", + "client_id": "client123", + "protected_endpoints": [ + { + "method": "GET", + "path": "/v1/mint/quote/bolt11" + } + ] + }"#; + + let settings: Settings = serde_json::from_str(json).unwrap(); + assert_eq!(settings.protected_endpoints.len(), 1); + assert_eq!(settings.protected_endpoints[0].method, Method::Get); + assert_eq!( + settings.protected_endpoints[0].path, + RoutePath::MintQuoteBolt11 + ); + } + + #[test] + fn test_settings_deserialize_all_paths() { + let json = r#"{ + "openid_discovery": "https://example.com/.well-known/openid-configuration", + "client_id": "client123", + "protected_endpoints": [ + { + "method": "GET", + "path": ".*" + } + ] + }"#; + + let settings: Settings = serde_json::from_str(json).unwrap(); + assert_eq!( + settings.protected_endpoints.len(), + RoutePath::iter().count() + ); + } +} diff --git a/crates/cashu/src/nuts/auth/nut22.rs b/crates/cashu/src/nuts/auth/nut22.rs new file mode 100644 index 00000000..2677cd44 --- /dev/null +++ b/crates/cashu/src/nuts/auth/nut22.rs @@ -0,0 +1,369 @@ +//! 22 Blind Auth + +use std::fmt; + +use bitcoin::base64::engine::general_purpose; +use bitcoin::base64::Engine; +use serde::{Deserialize, Serialize}; +use thiserror::Error; + +use super::nut21::ProtectedEndpoint; +use crate::dhke::hash_to_curve; +use crate::secret::Secret; +use crate::util::hex; +use crate::{BlindedMessage, Id, Proof, ProofDleq, PublicKey}; + +/// NUT22 Error +#[derive(Debug, Error)] +pub enum Error { + /// Invalid Prefix + #[error("Invalid prefix")] + InvalidPrefix, + /// Dleq proof not included + #[error("Dleq Proof not included for auth proof")] + DleqProofNotIncluded, + /// Hex Error + #[error(transparent)] + HexError(#[from] hex::Error), + /// Base64 error + #[error(transparent)] + Base64Error(#[from] bitcoin::base64::DecodeError), + /// Serde Json error + #[error(transparent)] + SerdeJsonError(#[from] serde_json::Error), + /// Utf8 parse error + #[error(transparent)] + Utf8ParseError(#[from] std::string::FromUtf8Error), + /// DHKE error + #[error(transparent)] + DHKE(#[from] crate::dhke::Error), +} + +/// Blind auth settings +#[derive(Debug, Clone, PartialEq, Eq, Hash, Default, Serialize)] +#[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))] +pub struct Settings { + /// Max number of blind auth tokens that can be minted per request + pub bat_max_mint: u64, + /// Protected endpoints + pub protected_endpoints: Vec, +} + +impl Settings { + /// Create new [`Settings`] + pub fn new(bat_max_mint: u64, protected_endpoints: Vec) -> Self { + Self { + bat_max_mint, + protected_endpoints, + } + } +} + +// Custom deserializer for Settings to expand regex patterns in protected endpoints +impl<'de> Deserialize<'de> for Settings { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + use std::collections::HashSet; + + use super::nut21::matching_route_paths; + + // Define a temporary struct to deserialize the raw data + #[derive(Deserialize)] + struct RawSettings { + bat_max_mint: u64, + protected_endpoints: Vec, + } + + #[derive(Deserialize)] + struct RawProtectedEndpoint { + method: super::nut21::Method, + path: String, + } + + // Deserialize into the temporary struct + let raw = RawSettings::deserialize(deserializer)?; + + // Process protected endpoints, expanding regex patterns if present + let mut protected_endpoints = HashSet::new(); + + for raw_endpoint in raw.protected_endpoints { + let expanded_paths = matching_route_paths(&raw_endpoint.path).map_err(|e| { + serde::de::Error::custom(format!( + "Invalid regex pattern '{}': {}", + raw_endpoint.path, e + )) + })?; + + for path in expanded_paths { + protected_endpoints.insert(super::nut21::ProtectedEndpoint::new( + raw_endpoint.method, + path, + )); + } + } + + // Create the final Settings struct + Ok(Settings { + bat_max_mint: raw.bat_max_mint, + protected_endpoints: protected_endpoints.into_iter().collect(), + }) + } +} + +/// Auth Token +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum AuthToken { + /// Clear Auth token + ClearAuth(String), + /// Blind Auth token + BlindAuth(BlindAuthToken), +} + +impl fmt::Display for AuthToken { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::ClearAuth(cat) => cat.fmt(f), + Self::BlindAuth(bat) => bat.fmt(f), + } + } +} + +impl AuthToken { + /// Header key for auth token type + pub fn header_key(&self) -> String { + match self { + Self::ClearAuth(_) => "Clear-auth".to_string(), + Self::BlindAuth(_) => "Blind-auth".to_string(), + } + } +} + +/// Required Auth +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)] +pub enum AuthRequired { + /// Clear Auth token + Clear, + /// Blind Auth token + Blind, +} + +/// Auth Proofs +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))] +pub struct AuthProof { + /// `Keyset id` + #[serde(rename = "id")] + pub keyset_id: Id, + /// Secret message + #[cfg_attr(feature = "swagger", schema(value_type = String))] + pub secret: Secret, + /// Unblinded signature + #[serde(rename = "C")] + #[cfg_attr(feature = "swagger", schema(value_type = String))] + pub c: PublicKey, + /// Auth Proof Dleq + pub dleq: ProofDleq, +} + +impl AuthProof { + /// Y of AuthProof + pub fn y(&self) -> Result { + Ok(hash_to_curve(self.secret.as_bytes())?) + } +} + +impl From for Proof { + fn from(value: AuthProof) -> Self { + Self { + amount: 1.into(), + keyset_id: value.keyset_id, + secret: value.secret, + c: value.c, + witness: None, + dleq: Some(value.dleq), + } + } +} + +impl TryFrom for AuthProof { + type Error = Error; + fn try_from(value: Proof) -> Result { + Ok(Self { + keyset_id: value.keyset_id, + secret: value.secret, + c: value.c, + dleq: value.dleq.ok_or({ + tracing::warn!("Dleq proof not included in auth"); + Error::DleqProofNotIncluded + })?, + }) + } +} + +/// Blind Auth Token +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub struct BlindAuthToken { + /// [AuthProof] + pub auth_proof: AuthProof, +} + +impl BlindAuthToken { + /// Create new [ `BlindAuthToken`] + pub fn new(auth_proof: AuthProof) -> Self { + BlindAuthToken { auth_proof } + } +} + +impl fmt::Display for BlindAuthToken { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + let json_string = serde_json::to_string(&self.auth_proof).map_err(|_| fmt::Error)?; + let encoded = general_purpose::URL_SAFE.encode(json_string); + write!(f, "authA{}", encoded) + } +} + +impl std::str::FromStr for BlindAuthToken { + type Err = Error; + + fn from_str(s: &str) -> Result { + // Check prefix and extract the base64 encoded part in one step + let encoded = s.strip_prefix("authA").ok_or(Error::InvalidPrefix)?; + + // Decode the base64 URL-safe string + let json_string = general_purpose::URL_SAFE.decode(encoded)?; + + // Convert bytes to UTF-8 string + let json_str = String::from_utf8(json_string)?; + + // Deserialize the JSON string into AuthProof + let auth_proof: AuthProof = serde_json::from_str(&json_str)?; + + Ok(BlindAuthToken { auth_proof }) + } +} + +/// Mint auth request [NUT-XX] +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +#[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))] +pub struct MintAuthRequest { + /// Outputs + #[cfg_attr(feature = "swagger", schema(max_items = 1_000))] + pub outputs: Vec, +} + +impl MintAuthRequest { + /// Count of tokens + pub fn amount(&self) -> u64 { + self.outputs.len() as u64 + } +} + +#[cfg(test)] +mod tests { + use std::collections::HashSet; + + use strum::IntoEnumIterator; + + use super::super::nut21::{Method, RoutePath}; + use super::*; + + #[test] + fn test_settings_deserialize_direct_paths() { + let json = r#"{ + "bat_max_mint": 10, + "protected_endpoints": [ + { + "method": "GET", + "path": "/v1/mint/bolt11" + }, + { + "method": "POST", + "path": "/v1/swap" + } + ] + }"#; + + let settings: Settings = serde_json::from_str(json).unwrap(); + + assert_eq!(settings.bat_max_mint, 10); + assert_eq!(settings.protected_endpoints.len(), 2); + + // Check that both paths are included + let paths = settings + .protected_endpoints + .iter() + .map(|ep| (ep.method, ep.path)) + .collect::>(); + assert!(paths.contains(&(Method::Get, RoutePath::MintBolt11))); + assert!(paths.contains(&(Method::Post, RoutePath::Swap))); + } + + #[test] + fn test_settings_deserialize_with_regex() { + let json = r#"{ + "bat_max_mint": 5, + "protected_endpoints": [ + { + "method": "GET", + "path": "^/v1/mint/.*" + }, + { + "method": "POST", + "path": "/v1/swap" + } + ] + }"#; + + let settings: Settings = serde_json::from_str(json).unwrap(); + + assert_eq!(settings.bat_max_mint, 5); + assert_eq!(settings.protected_endpoints.len(), 3); // 2 mint paths + 1 swap path + + let expected_protected: HashSet = HashSet::from_iter(vec![ + ProtectedEndpoint::new(Method::Post, RoutePath::Swap), + ProtectedEndpoint::new(Method::Get, RoutePath::MintBolt11), + ProtectedEndpoint::new(Method::Get, RoutePath::MintQuoteBolt11), + ]); + + let deserialized_protected = settings.protected_endpoints.into_iter().collect(); + + assert_eq!(expected_protected, deserialized_protected); + } + + #[test] + fn test_settings_deserialize_invalid_regex() { + let json = r#"{ + "bat_max_mint": 5, + "protected_endpoints": [ + { + "method": "GET", + "path": "(unclosed parenthesis" + } + ] + }"#; + + let result = serde_json::from_str::(json); + assert!(result.is_err()); + } + + #[test] + fn test_settings_deserialize_all_paths() { + let json = r#"{ + "bat_max_mint": 5, + "protected_endpoints": [ + { + "method": "GET", + "path": ".*" + } + ] + }"#; + + let settings: Settings = serde_json::from_str(json).unwrap(); + assert_eq!( + settings.protected_endpoints.len(), + RoutePath::iter().count() + ); + } +} diff --git a/crates/cashu/src/nuts/mod.rs b/crates/cashu/src/nuts/mod.rs index 0e24f000..5d76f8b1 100644 --- a/crates/cashu/src/nuts/mod.rs +++ b/crates/cashu/src/nuts/mod.rs @@ -24,6 +24,14 @@ pub mod nut18; pub mod nut19; pub mod nut20; +#[cfg(feature = "auth")] +mod auth; + +#[cfg(feature = "auth")] +pub use auth::{ + nut21, nut22, AuthProof, AuthRequired, AuthToken, BlindAuthSettings, BlindAuthToken, + ClearAuthSettings, Method, MintAuthRequest, ProtectedEndpoint, RoutePath, +}; pub use nut00::{ BlindSignature, BlindedMessage, CurrencyUnit, PaymentMethod, Proof, Proofs, ProofsMethods, Token, TokenV3, TokenV4, Witness, diff --git a/crates/cashu/src/nuts/nut00/mod.rs b/crates/cashu/src/nuts/nut00/mod.rs index ddd2b9fb..d8bf7a91 100644 --- a/crates/cashu/src/nuts/nut00/mod.rs +++ b/crates/cashu/src/nuts/nut00/mod.rs @@ -445,6 +445,8 @@ pub enum CurrencyUnit { Usd, /// Euro Eur, + /// Auth + Auth, /// Custom currency unit Custom(String), } @@ -458,6 +460,7 @@ impl CurrencyUnit { Self::Msat => Some(1), Self::Usd => Some(2), Self::Eur => Some(3), + Self::Auth => Some(4), _ => None, } } @@ -472,6 +475,7 @@ impl FromStr for CurrencyUnit { "MSAT" => Ok(Self::Msat), "USD" => Ok(Self::Usd), "EUR" => Ok(Self::Eur), + "AUTH" => Ok(Self::Auth), c => Ok(Self::Custom(c.to_string())), } } @@ -484,6 +488,7 @@ impl fmt::Display for CurrencyUnit { CurrencyUnit::Msat => "MSAT", CurrencyUnit::Usd => "USD", CurrencyUnit::Eur => "EUR", + CurrencyUnit::Auth => "AUTH", CurrencyUnit::Custom(unit) => unit, }; if let Some(width) = f.width() { diff --git a/crates/cashu/src/nuts/nut02.rs b/crates/cashu/src/nuts/nut02.rs index 95834383..4f7f5f90 100644 --- a/crates/cashu/src/nuts/nut02.rs +++ b/crates/cashu/src/nuts/nut02.rs @@ -16,7 +16,7 @@ use bitcoin::hashes::Hash; use bitcoin::key::Secp256k1; #[cfg(feature = "mint")] use bitcoin::secp256k1; -use serde::{Deserialize, Serialize}; +use serde::{Deserialize, Deserializer, Serialize}; use serde_with::{serde_as, VecSkipError}; use thiserror::Error; @@ -49,6 +49,7 @@ pub enum Error { /// Keyset version #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +#[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))] pub enum KeySetVersion { /// Current Version 00 Version00, @@ -85,6 +86,7 @@ impl fmt::Display for KeySetVersion { /// which mint or keyset it was generated from. #[derive(Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize)] #[serde(into = "String", try_from = "String")] +#[cfg_attr(feature = "swagger", derive(utoipa::ToSchema))] pub struct Id { version: KeySetVersion, id: [u8; Self::BYTELEN], @@ -258,10 +260,22 @@ pub struct KeySetInfo { /// Mint will only sign from an active keyset pub active: bool, /// Input Fee PPK - #[serde(default = "default_input_fee_ppk")] + #[serde( + deserialize_with = "deserialize_input_fee_ppk", + default = "default_input_fee_ppk" + )] pub input_fee_ppk: u64, } +fn deserialize_input_fee_ppk<'de, D>(deserializer: D) -> Result +where + D: Deserializer<'de>, +{ + // This will either give us a u64 or null (which becomes None) + let opt = Option::::deserialize(deserializer)?; + Ok(opt.unwrap_or_else(default_input_fee_ppk)) +} + fn default_input_fee_ppk() -> u64 { 0 } @@ -484,6 +498,10 @@ mod test { let h = r#"{"id":"009a1f293253e41e","unit":"sat","active":true}"#; let _keyset_response: KeySetInfo = serde_json::from_str(h).unwrap(); + + let h = r#"{"id":"009a1f293253e41e","unit":"sat","active":true, "input_fee_ppk":null}"#; + + let _keyset_response: KeySetInfo = serde_json::from_str(h).unwrap(); } #[test] diff --git a/crates/cashu/src/nuts/nut06.rs b/crates/cashu/src/nuts/nut06.rs index 264e7071..c13a9782 100644 --- a/crates/cashu/src/nuts/nut06.rs +++ b/crates/cashu/src/nuts/nut06.rs @@ -2,12 +2,17 @@ //! //! +#[cfg(feature = "auth")] +use std::collections::HashMap; + use serde::{Deserialize, Deserializer, Serialize, Serializer}; use super::nut01::PublicKey; use super::nut17::SupportedMethods; use super::nut19::CachedEndpoint; use super::{nut04, nut05, nut15, nut19, MppMethodSettings}; +#[cfg(feature = "auth")] +use super::{AuthRequired, BlindAuthSettings, ClearAuthSettings, ProtectedEndpoint}; /// Mint Version #[derive(Debug, Clone, PartialEq, Eq, Hash)] @@ -211,6 +216,46 @@ impl MintInfo { ..self } } + + /// Get protected endpoints + #[cfg(feature = "auth")] + pub fn protected_endpoints(&self) -> HashMap { + let mut protected_endpoints = HashMap::new(); + + if let Some(nut21_settings) = &self.nuts.nut21 { + for endpoint in nut21_settings.protected_endpoints.iter() { + protected_endpoints.insert(*endpoint, AuthRequired::Clear); + } + } + + if let Some(nut22_settings) = &self.nuts.nut22 { + for endpoint in nut22_settings.protected_endpoints.iter() { + protected_endpoints.insert(*endpoint, AuthRequired::Blind); + } + } + protected_endpoints + } + + /// Get Openid discovery of the mint if it is set + #[cfg(feature = "auth")] + pub fn openid_discovery(&self) -> Option { + self.nuts + .nut21 + .as_ref() + .map(|s| s.openid_discovery.to_string()) + } + + /// Get Openid discovery of the mint if it is set + #[cfg(feature = "auth")] + pub fn client_id(&self) -> Option { + self.nuts.nut21.as_ref().map(|s| s.client_id.clone()) + } + + /// Max bat mint + #[cfg(feature = "auth")] + pub fn bat_max_mint(&self) -> Option { + self.nuts.nut22.as_ref().map(|s| s.bat_max_mint) + } } /// Supported nuts and settings @@ -269,6 +314,16 @@ pub struct Nuts { #[serde(default)] #[serde(rename = "20")] pub nut20: SupportedSettings, + /// NUT21 Settings + #[serde(rename = "21")] + #[serde(skip_serializing_if = "Option::is_none")] + #[cfg(feature = "auth")] + pub nut21: Option, + /// NUT22 Settings + #[serde(rename = "22")] + #[serde(skip_serializing_if = "Option::is_none")] + #[cfg(feature = "auth")] + pub nut22: Option, } impl Nuts { diff --git a/crates/cdk-axum/Cargo.toml b/crates/cdk-axum/Cargo.toml index a02dd9de..d1bf6d18 100644 --- a/crates/cdk-axum/Cargo.toml +++ b/crates/cdk-axum/Cargo.toml @@ -8,9 +8,12 @@ repository = "https://github.com/cashubtc/cdk.git" rust-version = "1.75.0" # MSRV description = "Cashu CDK axum webserver" + [features] +default = ["auth"] redis = ["dep:redis"] swagger = ["cdk/swagger", "dep:utoipa"] +auth = ["cdk/auth"] [dependencies] anyhow.workspace = true @@ -18,7 +21,7 @@ async-trait.workspace = true axum = { workspace = true, features = ["ws"] } cdk = { workspace = true, features = [ "mint", -] } +]} tokio.workspace = true tracing.workspace = true utoipa = { workspace = true, optional = true } diff --git a/crates/cdk-axum/src/auth.rs b/crates/cdk-axum/src/auth.rs new file mode 100644 index 00000000..b4185ef0 --- /dev/null +++ b/crates/cdk-axum/src/auth.rs @@ -0,0 +1,194 @@ +use std::str::FromStr; + +use axum::extract::{FromRequestParts, State}; +use axum::http::request::Parts; +use axum::http::StatusCode; +use axum::response::Response; +use axum::routing::{get, post}; +use axum::{Json, Router}; +#[cfg(feature = "swagger")] +use cdk::error::ErrorResponse; +use cdk::nuts::{ + AuthToken, BlindAuthToken, KeysResponse, KeysetResponse, MintAuthRequest, MintBolt11Response, +}; +use serde::{Deserialize, Serialize}; + +#[cfg(feature = "auth")] +use crate::{get_keyset_pubkeys, into_response, MintState}; + +const CLEAR_AUTH_KEY: &str = "Clear-auth"; +const BLIND_AUTH_KEY: &str = "Blind-auth"; + +#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] +pub enum AuthHeader { + /// Clear Auth token + Clear(String), + /// Blind Auth token + Blind(BlindAuthToken), + /// No auth + None, +} + +impl From for Option { + fn from(value: AuthHeader) -> Option { + match value { + AuthHeader::Clear(token) => Some(AuthToken::ClearAuth(token)), + AuthHeader::Blind(token) => Some(AuthToken::BlindAuth(token)), + AuthHeader::None => None, + } + } +} + +impl FromRequestParts for AuthHeader +where + S: Send + Sync, +{ + type Rejection = (StatusCode, String); + + async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result { + // Check for Blind-auth header + if let Some(bat) = parts.headers.get(BLIND_AUTH_KEY) { + let token = bat + .to_str() + .map_err(|_| { + ( + StatusCode::BAD_REQUEST, + "Invalid Blind-auth header value".to_string(), + ) + })? + .to_string(); + + let token = BlindAuthToken::from_str(&token).map_err(|_| { + ( + StatusCode::BAD_REQUEST, + "Invalid Blind-auth header value".to_string(), + ) + })?; + + return Ok(AuthHeader::Blind(token)); + } + + // Check for Clear-auth header + if let Some(cat) = parts.headers.get(CLEAR_AUTH_KEY) { + let token = cat + .to_str() + .map_err(|_| { + ( + StatusCode::BAD_REQUEST, + "Invalid Clear-auth header value".to_string(), + ) + })? + .to_string(); + return Ok(AuthHeader::Clear(token)); + } + + // No authentication headers found - this is now valid + Ok(AuthHeader::None) + } +} + +#[cfg_attr(feature = "swagger", utoipa::path( + get, + context_path = "/v1/auth/blind", + path = "/keysets", + responses( + (status = 200, description = "Successful response", body = KeysetResponse, content_type = "application/json"), + (status = 500, description = "Server error", body = ErrorResponse, content_type = "application/json") + ) +))] +/// Get all active keyset IDs of the mint +/// +/// This endpoint returns a list of keysets that the mint currently supports and will accept tokens from. +#[cfg(feature = "auth")] +pub async fn get_auth_keysets( + State(state): State, +) -> Result, Response> { + let keysets = state.mint.auth_keysets().await.map_err(|err| { + tracing::error!("Could not get keysets: {}", err); + into_response(err) + })?; + + Ok(Json(keysets)) +} + +#[cfg_attr(feature = "swagger", utoipa::path( + get, + context_path = "/v1/auth/blind", + path = "/keys", + responses( + (status = 200, description = "Successful response", body = KeysResponse, content_type = "application/json") + ) +))] +/// Get the public keys of the newest blind auth mint keyset +/// +/// This endpoint returns a dictionary of all supported token values of the mint and their associated public key. +pub async fn get_blind_auth_keys( + State(state): State, +) -> Result, Response> { + let pubkeys = state.mint.auth_pubkeys().await.map_err(|err| { + tracing::error!("Could not get keys: {}", err); + into_response(err) + })?; + + Ok(Json(pubkeys)) +} + +/// Mint tokens by paying a BOLT11 Lightning invoice. +/// +/// Requests the minting of tokens belonging to a paid payment request. +/// +/// Call this endpoint after `POST /v1/mint/quote`. +#[cfg_attr(feature = "swagger", utoipa::path( + post, + context_path = "/v1/auth", + path = "/blind/mint", + request_body(content = MintAuthRequest, description = "Request params", content_type = "application/json"), + responses( + (status = 200, description = "Successful response", body = MintBolt11Response, content_type = "application/json"), + (status = 500, description = "Server error", body = ErrorResponse, content_type = "application/json") + ) +))] +pub async fn post_mint_auth( + auth: AuthHeader, + State(state): State, + Json(payload): Json, +) -> Result, Response> { + let auth_token = match auth { + AuthHeader::Clear(cat) => { + if cat.is_empty() { + tracing::debug!("Received blind auth mint request without cat"); + return Err(into_response(cdk::Error::ClearAuthRequired)); + } + + AuthToken::ClearAuth(cat) + } + _ => { + tracing::debug!("Received blind auth mint request without cat"); + return Err(into_response(cdk::Error::ClearAuthRequired)); + } + }; + + let res = state + .mint + .mint_blind_auth(auth_token, payload) + .await + .map_err(|err| { + tracing::error!("Could not process blind auth mint: {}", err); + into_response(err) + })?; + + Ok(Json(res)) +} + +pub fn create_auth_router(state: MintState) -> Router { + Router::new() + .nest( + "/auth/blind", + Router::new() + .route("/keys", get(get_blind_auth_keys)) + .route("/keysets", get(get_auth_keysets)) + .route("/keys/{keyset_id}", get(get_keyset_pubkeys)) + .route("/mint", post(post_mint_auth)), + ) + .with_state(state) +} diff --git a/crates/cdk-axum/src/lib.rs b/crates/cdk-axum/src/lib.rs index 6e696b56..f81808b3 100644 --- a/crates/cdk-axum/src/lib.rs +++ b/crates/cdk-axum/src/lib.rs @@ -6,12 +6,16 @@ use std::sync::Arc; use anyhow::Result; +#[cfg(feature = "auth")] +use auth::create_auth_router; use axum::routing::{get, post}; use axum::Router; use cache::HttpCache; use cdk::mint::Mint; use router_handlers::*; +#[cfg(feature = "auth")] +mod auth; pub mod cache; mod router_handlers; mod ws; @@ -165,7 +169,15 @@ pub async fn create_mint_router_with_custom_cache( .route("/info", get(get_mint_info)) .route("/restore", post(post_restore)); - let mint_router = Router::new().nest("/v1", v1_router).with_state(state); + let mint_router = Router::new().nest("/v1", v1_router); + + #[cfg(feature = "auth")] + let mint_router = { + let auth_router = create_auth_router(state.clone()); + mint_router.nest("/v1", auth_router) + }; + + let mint_router = mint_router.with_state(state); Ok(mint_router) } diff --git a/crates/cdk-axum/src/router_handlers.rs b/crates/cdk-axum/src/router_handlers.rs index 1960073a..97d0600a 100644 --- a/crates/cdk-axum/src/router_handlers.rs +++ b/crates/cdk-axum/src/router_handlers.rs @@ -4,6 +4,8 @@ use axum::extract::{Json, Path, State}; use axum::http::StatusCode; use axum::response::{IntoResponse, Response}; use cdk::error::ErrorResponse; +#[cfg(feature = "auth")] +use cdk::nuts::nut21::{Method, ProtectedEndpoint, RoutePath}; use cdk::nuts::{ CheckStateRequest, CheckStateResponse, Id, KeysResponse, KeysetResponse, MeltBolt11Request, MeltQuoteBolt11Request, MeltQuoteBolt11Response, MintBolt11Request, MintBolt11Response, @@ -15,6 +17,8 @@ use paste::paste; use tracing::instrument; use uuid::Uuid; +#[cfg(feature = "auth")] +use crate::auth::AuthHeader; use crate::ws::main_websocket; use crate::MintState; @@ -24,25 +28,29 @@ macro_rules! post_cache_wrapper { /// Cache wrapper function for $handler: /// Wrap $handler into a function that caches responses using the request as key pub async fn []( + #[cfg(feature = "auth")] auth: AuthHeader, state: State, payload: Json<$request_type> ) -> Result, Response> { use std::ops::Deref; - let json_extracted_payload = payload.deref(); let State(mint_state) = state.clone(); let cache_key = match mint_state.cache.calculate_key(&json_extracted_payload) { Some(key) => key, None => { // Could not calculate key, just return the handler result - return $handler(state, payload).await; + #[cfg(feature = "auth")] + return $handler(auth, state, payload).await; + #[cfg(not(feature = "auth"))] + return $handler( state, payload).await; } }; - if let Some(cached_response) = mint_state.cache.get::<$response_type>(&cache_key).await { return Ok(Json(cached_response)); } - + #[cfg(feature = "auth")] + let response = $handler(auth, state, payload).await?; + #[cfg(not(feature = "auth"))] let response = $handler(state, payload).await?; mint_state.cache.set(cache_key, &response.deref()).await; Ok(response) @@ -74,7 +82,10 @@ post_cache_wrapper!( /// Get the public keys of the newest mint keyset /// /// This endpoint returns a dictionary of all supported token values of the mint and their associated public key. -pub async fn get_keys(State(state): State) -> Result, Response> { +#[instrument(skip_all)] +pub(crate) async fn get_keys( + State(state): State, +) -> Result, Response> { let pubkeys = state.mint.pubkeys().await.map_err(|err| { tracing::error!("Could not get keys: {}", err); into_response(err) @@ -98,7 +109,8 @@ pub async fn get_keys(State(state): State) -> Result, Path(keyset_id): Path, ) -> Result, Response> { @@ -122,7 +134,10 @@ pub async fn get_keyset_pubkeys( /// Get all active keyset IDs of the mint /// /// This endpoint returns a list of keysets that the mint currently supports and will accept tokens from. -pub async fn get_keysets(State(state): State) -> Result, Response> { +#[instrument(skip_all)] +pub(crate) async fn get_keysets( + State(state): State, +) -> Result, Response> { let keysets = state.mint.keysets().await.map_err(|err| { tracing::error!("Could not get keysets: {}", err); into_response(err) @@ -144,10 +159,22 @@ pub async fn get_keysets(State(state): State) -> Result, Json(payload): Json, ) -> Result>, Response> { + #[cfg(feature = "auth")] + state + .mint + .verify_auth( + auth.into(), + &ProtectedEndpoint::new(Method::Post, RoutePath::MintQuoteBolt11), + ) + .await + .map_err(into_response)?; + let quote = state .mint .get_mint_bolt11_quote(payload) @@ -172,10 +199,24 @@ pub async fn post_mint_bolt11_quote( /// Get mint quote by ID /// /// Get mint quote state. -pub async fn get_check_mint_bolt11_quote( +#[instrument(skip_all, fields(quote_id = ?quote_id))] +pub(crate) async fn get_check_mint_bolt11_quote( + #[cfg(feature = "auth")] auth: AuthHeader, State(state): State, Path(quote_id): Path, ) -> Result>, Response> { + #[cfg(feature = "auth")] + { + state + .mint + .verify_auth( + auth.into(), + &ProtectedEndpoint::new(Method::Get, RoutePath::MintQuoteBolt11), + ) + .await + .map_err(into_response)?; + } + let quote = state .mint .check_mint_quote("e_id) @@ -188,7 +229,11 @@ pub async fn get_check_mint_bolt11_quote( Ok(Json(quote)) } -pub async fn ws_handler(State(state): State, ws: WebSocketUpgrade) -> impl IntoResponse { +#[instrument(skip_all)] +pub(crate) async fn ws_handler( + State(state): State, + ws: WebSocketUpgrade, +) -> impl IntoResponse { ws.on_upgrade(|ws| main_websocket(ws, state)) } @@ -207,10 +252,24 @@ pub async fn ws_handler(State(state): State, ws: WebSocketUpgrade) -> (status = 500, description = "Server error", body = ErrorResponse, content_type = "application/json") ) ))] -pub async fn post_mint_bolt11( +#[instrument(skip_all, fields(quote_id = ?payload.quote))] +pub(crate) async fn post_mint_bolt11( + #[cfg(feature = "auth")] auth: AuthHeader, State(state): State, Json(payload): Json>, ) -> Result, Response> { + #[cfg(feature = "auth")] + { + state + .mint + .verify_auth( + auth.into(), + &ProtectedEndpoint::new(Method::Post, RoutePath::MintBolt11), + ) + .await + .map_err(into_response)?; + } + let res = state .mint .process_mint_request(payload) @@ -235,10 +294,23 @@ pub async fn post_mint_bolt11( ))] #[instrument(skip_all)] /// Request a quote for melting tokens -pub async fn post_melt_bolt11_quote( +pub(crate) async fn post_melt_bolt11_quote( + #[cfg(feature = "auth")] auth: AuthHeader, State(state): State, Json(payload): Json, ) -> Result>, Response> { + #[cfg(feature = "auth")] + { + state + .mint + .verify_auth( + auth.into(), + &ProtectedEndpoint::new(Method::Post, RoutePath::MeltQuoteBolt11), + ) + .await + .map_err(into_response)?; + } + let quote = state .mint .get_melt_bolt11_quote(&payload) @@ -263,11 +335,24 @@ pub async fn post_melt_bolt11_quote( /// Get melt quote by ID /// /// Get melt quote state. -#[instrument(skip_all)] -pub async fn get_check_melt_bolt11_quote( +#[instrument(skip_all, fields(quote_id = ?quote_id))] +pub(crate) async fn get_check_melt_bolt11_quote( + #[cfg(feature = "auth")] auth: AuthHeader, State(state): State, Path(quote_id): Path, ) -> Result>, Response> { + #[cfg(feature = "auth")] + { + state + .mint + .verify_auth( + auth.into(), + &ProtectedEndpoint::new(Method::Get, RoutePath::MeltQuoteBolt11), + ) + .await + .map_err(into_response)?; + } + let quote = state .mint .check_melt_quote("e_id) @@ -294,10 +379,23 @@ pub async fn get_check_melt_bolt11_quote( /// /// Requests tokens to be destroyed and sent out via Lightning. #[instrument(skip_all)] -pub async fn post_melt_bolt11( +pub(crate) async fn post_melt_bolt11( + #[cfg(feature = "auth")] auth: AuthHeader, State(state): State, Json(payload): Json>, ) -> Result>, Response> { + #[cfg(feature = "auth")] + { + state + .mint + .verify_auth( + auth.into(), + &ProtectedEndpoint::new(Method::Post, RoutePath::MeltBolt11), + ) + .await + .map_err(into_response)?; + } + let res = state .mint .melt_bolt11(&payload) @@ -320,10 +418,24 @@ pub async fn post_melt_bolt11( /// Check whether a proof is spent already or is pending in a transaction /// /// Check whether a secret has been spent already or not. -pub async fn post_check( +#[instrument(skip_all, fields(y_count = ?payload.ys.len()))] +pub(crate) async fn post_check( + #[cfg(feature = "auth")] auth: AuthHeader, State(state): State, Json(payload): Json, ) -> Result, Response> { + #[cfg(feature = "auth")] + { + state + .mint + .verify_auth( + auth.into(), + &ProtectedEndpoint::new(Method::Post, RoutePath::Checkstate), + ) + .await + .map_err(into_response)?; + } + let state = state.mint.check_state(&payload).await.map_err(|err| { tracing::error!("Could not check state of proofs"); into_response(err) @@ -341,7 +453,10 @@ pub async fn post_check( ) ))] /// Mint information, operator contact information, and other info -pub async fn get_mint_info(State(state): State) -> Result, Response> { +#[instrument(skip_all)] +pub(crate) async fn get_mint_info( + State(state): State, +) -> Result, Response> { Ok(Json( state .mint @@ -371,10 +486,24 @@ pub async fn get_mint_info(State(state): State) -> Result, Json(payload): Json, ) -> Result, Response> { + #[cfg(feature = "auth")] + { + state + .mint + .verify_auth( + auth.into(), + &ProtectedEndpoint::new(Method::Post, RoutePath::Swap), + ) + .await + .map_err(into_response)?; + } + let swap_response = state .mint .process_swap_request(payload) @@ -383,6 +512,7 @@ pub async fn post_swap( tracing::error!("Could not process swap request: {}", err); into_response(err) })?; + Ok(Json(swap_response)) } @@ -397,10 +527,24 @@ pub async fn post_swap( ) ))] /// Restores blind signature for a set of outputs. -pub async fn post_restore( +#[instrument(skip_all, fields(outputs_count = ?payload.outputs.len()))] +pub(crate) async fn post_restore( + #[cfg(feature = "auth")] auth: AuthHeader, State(state): State, Json(payload): Json, ) -> Result, Response> { + #[cfg(feature = "auth")] + { + state + .mint + .verify_auth( + auth.into(), + &ProtectedEndpoint::new(Method::Post, RoutePath::Restore), + ) + .await + .map_err(into_response)?; + } + let restore_response = state.mint.restore(payload).await.map_err(|err| { tracing::error!("Could not process restore: {}", err); into_response(err) @@ -409,7 +553,8 @@ pub async fn post_restore( Ok(Json(restore_response)) } -pub fn into_response(error: T) -> Response +#[instrument(skip_all)] +pub(crate) fn into_response(error: T) -> Response where T: Into, { diff --git a/crates/cdk-cli/Cargo.toml b/crates/cdk-cli/Cargo.toml index aaf814f7..cc202a3d 100644 --- a/crates/cdk-cli/Cargo.toml +++ b/crates/cdk-cli/Cargo.toml @@ -15,7 +15,7 @@ sqlcipher = ["cdk-sqlite/sqlcipher"] [dependencies] anyhow.workspace = true bip39.workspace = true -cdk = { workspace = true, default-features = false, features = ["wallet"]} +cdk = { workspace = true, default-features = false, features = ["wallet", "auth"]} cdk-redb = { workspace = true, features = ["wallet"] } cdk-sqlite = { workspace = true, features = ["wallet"] } clap.workspace = true diff --git a/crates/cdk-cli/src/main.rs b/crates/cdk-cli/src/main.rs index 91f18084..f89ce145 100644 --- a/crates/cdk-cli/src/main.rs +++ b/crates/cdk-cli/src/main.rs @@ -8,8 +8,7 @@ use bip39::rand::{thread_rng, Rng}; use bip39::Mnemonic; use cdk::cdk_database; use cdk::cdk_database::WalletDatabase; -use cdk::wallet::client::HttpClient; -use cdk::wallet::{MultiMintWallet, Wallet}; +use cdk::wallet::{HttpClient, MultiMintWallet, Wallet, WalletBuilder}; use cdk_redb::WalletRedbDatabase; use cdk_sqlite::WalletSqliteDatabase; use clap::{Parser, Subcommand}; @@ -19,6 +18,7 @@ use url::Url; mod nostr_storage; mod sub_commands; +mod token_storage; const DEFAULT_WORK_DIR: &str = ".cdk-cli"; @@ -83,6 +83,12 @@ enum Commands { PayRequest(sub_commands::pay_request::PayRequestSubCommand), /// Create Payment request CreateRequest(sub_commands::create_request::CreateRequestSubCommand), + /// Mint blind auth proofs + MintBlindAuth(sub_commands::mint_blind_auth::MintBlindAuthSubCommand), + /// Cat login with username/password + CatLogin(sub_commands::cat_login::CatLoginSubCommand), + /// Cat login with device code flow + CatDeviceLogin(sub_commands::cat_device_login::CatDeviceLoginSubCommand), } #[tokio::main] @@ -158,18 +164,19 @@ async fn main() -> Result<()> { let mints = localstore.get_mints().await?; for (mint_url, _) in mints { - let mut wallet = Wallet::new( - &mint_url.to_string(), - cdk::nuts::CurrencyUnit::Sat, - localstore.clone(), - &mnemonic.to_seed_normalized(""), - None, - )?; + let mut builder = WalletBuilder::new() + .mint_url(mint_url.clone()) + .unit(cdk::nuts::CurrencyUnit::Sat) + .localstore(localstore.clone()) + .seed(&mnemonic.to_seed_normalized("")); + if let Some(proxy_url) = args.proxy.as_ref() { let http_client = HttpClient::with_proxy(mint_url, proxy_url.clone(), None, true)?; - wallet.set_client(http_client); + builder = builder.client(http_client); } + let wallet = builder.build()?; + wallets.push(wallet); } @@ -242,5 +249,35 @@ async fn main() -> Result<()> { Commands::CreateRequest(sub_command_args) => { sub_commands::create_request::create_request(&multi_mint_wallet, sub_command_args).await } + Commands::MintBlindAuth(sub_command_args) => { + sub_commands::mint_blind_auth::mint_blind_auth( + &multi_mint_wallet, + &mnemonic.to_seed_normalized(""), + localstore, + sub_command_args, + &work_dir, + ) + .await + } + Commands::CatLogin(sub_command_args) => { + sub_commands::cat_login::cat_login( + &multi_mint_wallet, + &mnemonic.to_seed_normalized(""), + localstore, + sub_command_args, + &work_dir, + ) + .await + } + Commands::CatDeviceLogin(sub_command_args) => { + sub_commands::cat_device_login::cat_device_login( + &multi_mint_wallet, + &mnemonic.to_seed_normalized(""), + localstore, + sub_command_args, + &work_dir, + ) + .await + } } } diff --git a/crates/cdk-cli/src/sub_commands/cat_device_login.rs b/crates/cdk-cli/src/sub_commands/cat_device_login.rs new file mode 100644 index 00000000..12b885fe --- /dev/null +++ b/crates/cdk-cli/src/sub_commands/cat_device_login.rs @@ -0,0 +1,196 @@ +use std::path::Path; +use std::str::FromStr; +use std::sync::Arc; +use std::time::Duration; + +use anyhow::Result; +use cdk::cdk_database::{Error, WalletDatabase}; +use cdk::mint_url::MintUrl; +use cdk::nuts::{CurrencyUnit, MintInfo}; +use cdk::wallet::types::WalletKey; +use cdk::wallet::{MultiMintWallet, Wallet}; +use cdk::OidcClient; +use clap::Args; +use serde::{Deserialize, Serialize}; +use tokio::time::sleep; + +use crate::token_storage; + +#[derive(Args, Serialize, Deserialize)] +pub struct CatDeviceLoginSubCommand { + /// Mint url + mint_url: MintUrl, + /// Currency unit e.g. sat + #[arg(default_value = "sat")] + #[arg(short, long)] + unit: String, + /// Client ID for OIDC authentication + #[arg(default_value = "cashu-client")] + #[arg(long)] + client_id: String, +} + +pub async fn cat_device_login( + multi_mint_wallet: &MultiMintWallet, + seed: &[u8], + localstore: Arc + Sync + Send>, + sub_command_args: &CatDeviceLoginSubCommand, + work_dir: &Path, +) -> Result<()> { + let mint_url = sub_command_args.mint_url.clone(); + let unit = CurrencyUnit::from_str(&sub_command_args.unit)?; + + let wallet = match multi_mint_wallet + .get_wallet(&WalletKey::new(mint_url.clone(), unit.clone())) + .await + { + Some(wallet) => wallet.clone(), + None => { + let wallet = Wallet::new(&mint_url.to_string(), unit, localstore, seed, None)?; + + multi_mint_wallet.add_wallet(wallet.clone()).await; + wallet + } + }; + + let mint_info = wallet.get_mint_info().await?.expect("Mint info not found"); + + let (access_token, refresh_token) = + get_device_code_token(&mint_info, &sub_command_args.client_id).await; + + // Save tokens to file in work directory + if let Err(e) = + token_storage::save_tokens(work_dir, &mint_url, &access_token, &refresh_token).await + { + println!("Warning: Failed to save tokens to file: {}", e); + } else { + println!("Tokens saved to work directory"); + } + + // Print a cute ASCII cat + println!("\nAuthentication successful! 🎉\n"); + println!("\nYour tokens:"); + println!("access_token: {}", access_token); + println!("refresh_token: {}", refresh_token); + + Ok(()) +} + +async fn get_device_code_token(mint_info: &MintInfo, client_id: &str) -> (String, String) { + let openid_discovery = mint_info + .nuts + .nut21 + .clone() + .expect("Nut21 defined") + .openid_discovery; + + let oidc_client = OidcClient::new(openid_discovery); + + // Get the OIDC configuration + let oidc_config = oidc_client + .get_oidc_config() + .await + .expect("Failed to get OIDC config"); + + // Get the device authorization endpoint + let device_auth_url = oidc_config.device_authorization_endpoint; + + // Make the device code request + let client = reqwest::Client::new(); + let device_code_response = client + .post(device_auth_url) + .form(&[("client_id", client_id)]) + .send() + .await + .expect("Failed to send device code request"); + + let device_code_data: serde_json::Value = device_code_response + .json() + .await + .expect("Failed to parse device code response"); + + let device_code = device_code_data["device_code"] + .as_str() + .expect("No device code in response"); + + let user_code = device_code_data["user_code"] + .as_str() + .expect("No user code in response"); + + let verification_uri = device_code_data["verification_uri"] + .as_str() + .expect("No verification URI in response"); + + let verification_uri_complete = device_code_data["verification_uri_complete"] + .as_str() + .unwrap_or(verification_uri); + + let interval = device_code_data["interval"].as_u64().unwrap_or(5); + + println!("\nTo login, visit: {}", verification_uri); + println!("And enter code: {}\n", user_code); + + if verification_uri_complete != verification_uri { + println!( + "Or visit this URL directly: {}\n", + verification_uri_complete + ); + } + + // Poll for the token + let token_url = oidc_config.token_endpoint; + + loop { + sleep(Duration::from_secs(interval)).await; + + let token_response = client + .post(&token_url) + .form(&[ + ("grant_type", "urn:ietf:params:oauth:grant-type:device_code"), + ("device_code", device_code), + ("client_id", client_id), + ]) + .send() + .await + .expect("Failed to send token request"); + + if token_response.status().is_success() { + let token_data: serde_json::Value = token_response + .json() + .await + .expect("Failed to parse token response"); + + let access_token = token_data["access_token"] + .as_str() + .expect("No access token in response") + .to_string(); + + let refresh_token = token_data["refresh_token"] + .as_str() + .expect("No refresh token in response") + .to_string(); + + return (access_token, refresh_token); + } else { + let error_data: serde_json::Value = token_response + .json() + .await + .expect("Failed to parse error response"); + + let error = error_data["error"].as_str().unwrap_or("unknown_error"); + + // If the user hasn't completed the flow yet, continue polling + if error == "authorization_pending" || error == "slow_down" { + if error == "slow_down" { + // If we're polling too fast, slow down + sleep(Duration::from_secs(interval + 5)).await; + } + println!("Waiting for user to complete authentication..."); + continue; + } else { + // For other errors, exit with an error message + panic!("Authentication failed: {}", error); + } + } + } +} diff --git a/crates/cdk-cli/src/sub_commands/cat_login.rs b/crates/cdk-cli/src/sub_commands/cat_login.rs new file mode 100644 index 00000000..6a06dfb9 --- /dev/null +++ b/crates/cdk-cli/src/sub_commands/cat_login.rs @@ -0,0 +1,140 @@ +use std::path::Path; +use std::str::FromStr; +use std::sync::Arc; + +use anyhow::Result; +use cdk::cdk_database::{Error, WalletDatabase}; +use cdk::mint_url::MintUrl; +use cdk::nuts::{CurrencyUnit, MintInfo}; +use cdk::wallet::types::WalletKey; +use cdk::wallet::{MultiMintWallet, Wallet}; +use cdk::OidcClient; +use clap::Args; +use serde::{Deserialize, Serialize}; + +use crate::token_storage; + +#[derive(Args, Serialize, Deserialize)] +pub struct CatLoginSubCommand { + /// Mint url + mint_url: MintUrl, + /// Username + username: String, + /// Password + password: String, + /// Currency unit e.g. sat + #[arg(default_value = "sat")] + #[arg(short, long)] + unit: String, + /// Client ID for OIDC authentication + #[arg(default_value = "cashu-client")] + #[arg(long)] + client_id: String, +} + +pub async fn cat_login( + multi_mint_wallet: &MultiMintWallet, + seed: &[u8], + localstore: Arc + Sync + Send>, + sub_command_args: &CatLoginSubCommand, + work_dir: &Path, +) -> Result<()> { + let mint_url = sub_command_args.mint_url.clone(); + let unit = CurrencyUnit::from_str(&sub_command_args.unit)?; + + let wallet = match multi_mint_wallet + .get_wallet(&WalletKey::new(mint_url.clone(), unit.clone())) + .await + { + Some(wallet) => wallet.clone(), + None => { + let wallet = Wallet::new(&mint_url.to_string(), unit, localstore, seed, None)?; + + multi_mint_wallet.add_wallet(wallet.clone()).await; + wallet + } + }; + + let mint_info = wallet.get_mint_info().await?.expect("Mint info not found"); + + let (access_token, refresh_token) = get_access_token( + &mint_info, + &sub_command_args.client_id, + &sub_command_args.username, + &sub_command_args.password, + ) + .await; + + // Save tokens to file in work directory + if let Err(e) = + token_storage::save_tokens(work_dir, &mint_url, &access_token, &refresh_token).await + { + println!("Warning: Failed to save tokens to file: {}", e); + } else { + println!("Tokens saved to work directory"); + } + + println!("\nAuthentication successful! 🎉\n"); + println!("\nYour tokens:"); + println!("access_token: {}", access_token); + println!("refresh_token: {}", refresh_token); + + Ok(()) +} + +async fn get_access_token( + mint_info: &MintInfo, + client_id: &str, + user: &str, + password: &str, +) -> (String, String) { + let openid_discovery = mint_info + .nuts + .nut21 + .clone() + .expect("Nut21 defined") + .openid_discovery; + + let oidc_client = OidcClient::new(openid_discovery); + + // Get the token endpoint from the OIDC configuration + let token_url = oidc_client + .get_oidc_config() + .await + .expect("Failed to get OIDC config") + .token_endpoint; + + // Create the request parameters + let params = [ + ("grant_type", "password"), + ("client_id", client_id), + ("username", user), + ("password", password), + ]; + + // Make the token request directly + let client = reqwest::Client::new(); + let response = client + .post(token_url) + .form(¶ms) + .send() + .await + .expect("Failed to send token request"); + + let token_response: serde_json::Value = response + .json() + .await + .expect("Failed to parse token response"); + + let access_token = token_response["access_token"] + .as_str() + .expect("No access token in response") + .to_string(); + + let refresh_token = token_response["refresh_token"] + .as_str() + .expect("No refresh token in response") + .to_string(); + + (access_token, refresh_token) +} diff --git a/crates/cdk-cli/src/sub_commands/mint.rs b/crates/cdk-cli/src/sub_commands/mint.rs index 07b4ef46..9b465c45 100644 --- a/crates/cdk-cli/src/sub_commands/mint.rs +++ b/crates/cdk-cli/src/sub_commands/mint.rs @@ -53,6 +53,8 @@ pub async fn mint( } }; + wallet.get_mint_info().await?; + let quote_id = match &sub_command_args.quote_id { None => { let amount = sub_command_args diff --git a/crates/cdk-cli/src/sub_commands/mint_blind_auth.rs b/crates/cdk-cli/src/sub_commands/mint_blind_auth.rs new file mode 100644 index 00000000..a13d0ff7 --- /dev/null +++ b/crates/cdk-cli/src/sub_commands/mint_blind_auth.rs @@ -0,0 +1,209 @@ +use std::path::Path; +use std::str::FromStr; +use std::sync::Arc; + +use anyhow::{anyhow, Result}; +use cdk::cdk_database::{Error, WalletDatabase}; +use cdk::mint_url::MintUrl; +use cdk::nuts::{CurrencyUnit, MintInfo}; +use cdk::wallet::types::WalletKey; +use cdk::wallet::{MultiMintWallet, Wallet}; +use cdk::{Amount, OidcClient}; +use clap::Args; +use serde::{Deserialize, Serialize}; + +use crate::token_storage; + +#[derive(Args, Serialize, Deserialize)] +pub struct MintBlindAuthSubCommand { + /// Mint url + mint_url: MintUrl, + /// Amount + amount: Option, + /// Cat (access token) + #[arg(long)] + cat: Option, + /// Currency unit e.g. sat + #[arg(default_value = "sat")] + #[arg(short, long)] + unit: String, +} + +pub async fn mint_blind_auth( + multi_mint_wallet: &MultiMintWallet, + seed: &[u8], + localstore: Arc + Sync + Send>, + sub_command_args: &MintBlindAuthSubCommand, + work_dir: &Path, +) -> Result<()> { + let mint_url = sub_command_args.mint_url.clone(); + let unit = CurrencyUnit::from_str(&sub_command_args.unit)?; + + let wallet = match multi_mint_wallet + .get_wallet(&WalletKey::new(mint_url.clone(), unit.clone())) + .await + { + Some(wallet) => wallet.clone(), + None => { + let wallet = Wallet::new(&mint_url.to_string(), unit, localstore, seed, None)?; + + multi_mint_wallet.add_wallet(wallet.clone()).await; + wallet + } + }; + + wallet.get_mint_info().await?; + + // Try to get the token from the provided argument or from the stored file + let cat = match &sub_command_args.cat { + Some(token) => token.clone(), + None => { + // Try to load from file + match token_storage::get_token_for_mint(work_dir, &mint_url).await { + Ok(Some(token_data)) => { + println!("Using access token from cashu_tokens.json"); + token_data.access_token + } + Ok(None) => { + return Err(anyhow::anyhow!( + "No access token provided and no token found in cashu_tokens.json for this mint" + )); + } + Err(e) => { + return Err(anyhow::anyhow!( + "Failed to read token from cashu_tokens.json: {}", + e + )); + } + } + } + }; + + // Try to set the access token + if let Err(err) = wallet.set_cat(cat.clone()).await { + tracing::error!("Could not set cat: {}", err); + + // Try to refresh the token if we have a refresh token + if let Ok(Some(token_data)) = token_storage::get_token_for_mint(work_dir, &mint_url).await { + println!("Attempting to refresh the access token..."); + + // Get the mint info to access OIDC configuration + if let Some(mint_info) = wallet.get_mint_info().await? { + match refresh_access_token(&mint_info, &token_data.refresh_token).await { + Ok((new_access_token, new_refresh_token)) => { + println!("Successfully refreshed access token"); + + // Save the new tokens + if let Err(e) = token_storage::save_tokens( + work_dir, + &mint_url, + &new_access_token, + &new_refresh_token, + ) + .await + { + println!("Warning: Failed to save refreshed tokens: {}", e); + } + + // Try setting the new access token + if let Err(err) = wallet.set_cat(new_access_token).await { + tracing::error!("Could not set refreshed cat: {}", err); + return Err(anyhow::anyhow!( + "Authentication failed even after token refresh" + )); + } + + // Set the refresh token + wallet.set_refresh_token(new_refresh_token).await?; + } + Err(e) => { + tracing::error!("Failed to refresh token: {}", e); + return Err(anyhow::anyhow!("Failed to refresh access token: {}", e)); + } + } + } + } else { + return Err(anyhow::anyhow!( + "Authentication failed and no refresh token available" + )); + } + } else { + // If we have a refresh token, set it + if let Ok(Some(token_data)) = token_storage::get_token_for_mint(work_dir, &mint_url).await { + tracing::info!("Attempting to use refresh access token to refresh auth token"); + wallet.set_refresh_token(token_data.refresh_token).await?; + wallet.refresh_access_token().await?; + } + } + + println!("Attempting to mint blind auth"); + + let amount = match sub_command_args.amount { + Some(amount) => amount, + None => { + let mint_info = wallet + .get_mint_info() + .await? + .ok_or(anyhow!("Unknown mint info"))?; + mint_info + .bat_max_mint() + .ok_or(anyhow!("Unknown max bat mint"))? + } + }; + + let proofs = wallet.mint_blind_auth(Amount::from(amount)).await?; + + println!("Received {} auth proofs for mint {mint_url}", proofs.len()); + + Ok(()) +} + +async fn refresh_access_token( + mint_info: &MintInfo, + refresh_token: &str, +) -> Result<(String, String)> { + let openid_discovery = mint_info + .nuts + .nut21 + .clone() + .ok_or_else(|| anyhow::anyhow!("OIDC discovery information not available"))? + .openid_discovery; + + let oidc_client = OidcClient::new(openid_discovery); + + // Get the token endpoint from the OIDC configuration + let token_url = oidc_client.get_oidc_config().await?.token_endpoint; + + // Create the request parameters for token refresh + let params = [ + ("grant_type", "refresh_token"), + ("refresh_token", refresh_token), + ("client_id", "cashu-client"), // Using default client ID + ]; + + // Make the token refresh request + let client = reqwest::Client::new(); + let response = client.post(token_url).form(¶ms).send().await?; + + if !response.status().is_success() { + return Err(anyhow::anyhow!( + "Token refresh failed with status: {}", + response.status() + )); + } + + let token_response: serde_json::Value = response.json().await?; + + let access_token = token_response["access_token"] + .as_str() + .ok_or_else(|| anyhow::anyhow!("No access token in refresh response"))? + .to_string(); + + // Get the new refresh token or use the old one if not provided + let new_refresh_token = token_response["refresh_token"] + .as_str() + .unwrap_or(refresh_token) + .to_string(); + + Ok((access_token, new_refresh_token)) +} diff --git a/crates/cdk-cli/src/sub_commands/mint_info.rs b/crates/cdk-cli/src/sub_commands/mint_info.rs index b2dc0b1c..abad6266 100644 --- a/crates/cdk-cli/src/sub_commands/mint_info.rs +++ b/crates/cdk-cli/src/sub_commands/mint_info.rs @@ -1,6 +1,6 @@ use anyhow::Result; use cdk::mint_url::MintUrl; -use cdk::wallet::client::MintConnector; +use cdk::wallet::MintConnector; use cdk::HttpClient; use clap::Args; use url::Url; @@ -14,7 +14,7 @@ pub async fn mint_info(proxy: Option, sub_command_args: &MintInfoSubcommand let mint_url = sub_command_args.mint_url.clone(); let client = match proxy { Some(proxy) => HttpClient::with_proxy(mint_url, proxy, None, true)?, - None => HttpClient::new(mint_url), + None => HttpClient::new(mint_url, None), }; let info = client.get_mint_info().await?; diff --git a/crates/cdk-cli/src/sub_commands/mod.rs b/crates/cdk-cli/src/sub_commands/mod.rs index 8256d0ae..92cd9452 100644 --- a/crates/cdk-cli/src/sub_commands/mod.rs +++ b/crates/cdk-cli/src/sub_commands/mod.rs @@ -1,5 +1,7 @@ pub mod balance; pub mod burn; +pub mod cat_device_login; +pub mod cat_login; pub mod check_spent; pub mod create_request; pub mod decode_request; @@ -7,6 +9,7 @@ pub mod decode_token; pub mod list_mint_proofs; pub mod melt; pub mod mint; +pub mod mint_blind_auth; pub mod mint_info; pub mod pay_request; pub mod pending_mints; diff --git a/crates/cdk-cli/src/token_storage.rs b/crates/cdk-cli/src/token_storage.rs new file mode 100644 index 00000000..8cb8dba3 --- /dev/null +++ b/crates/cdk-cli/src/token_storage.rs @@ -0,0 +1,62 @@ +use std::fs::File; +use std::io::{Read, Write}; +use std::path::Path; + +use anyhow::Result; +use cdk::mint_url::MintUrl; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Serialize, Deserialize)] +pub struct TokenData { + pub mint_url: String, + pub access_token: String, + pub refresh_token: String, +} + +/// Stores authentication tokens in the work directory +pub async fn save_tokens( + work_dir: &Path, + mint_url: &MintUrl, + access_token: &str, + refresh_token: &str, +) -> Result<()> { + let token_data = TokenData { + mint_url: mint_url.to_string(), + access_token: access_token.to_string(), + refresh_token: refresh_token.to_string(), + }; + + let json = serde_json::to_string_pretty(&token_data)?; + let file_path = work_dir.join(format!( + "auth_tokens_{}", + mint_url.to_string().replace("/", "_") + )); + let mut file = File::create(file_path)?; + file.write_all(json.as_bytes())?; + + Ok(()) +} + +/// Gets authentication tokens from the work directory +pub async fn get_token_for_mint(work_dir: &Path, mint_url: &MintUrl) -> Result> { + let file_path = work_dir.join(format!( + "auth_tokens_{}", + mint_url.to_string().replace("/", "_") + )); + + if !file_path.exists() { + return Ok(None); + } + + let mut file = File::open(file_path)?; + let mut contents = String::new(); + file.read_to_string(&mut contents)?; + + let token_data: TokenData = serde_json::from_str(&contents)?; + + if token_data.mint_url == mint_url.to_string() { + Ok(Some(token_data)) + } else { + Ok(None) + } +} diff --git a/crates/cdk-common/Cargo.toml b/crates/cdk-common/Cargo.toml index 4aca1428..1afe6763 100644 --- a/crates/cdk-common/Cargo.toml +++ b/crates/cdk-common/Cargo.toml @@ -15,6 +15,7 @@ swagger = ["dep:utoipa", "cashu/swagger"] bench = [] wallet = ["cashu/wallet"] mint = ["cashu/mint", "dep:uuid"] +auth = ["cashu/auth"] [dependencies] async-trait.workspace = true diff --git a/crates/cdk-common/src/database/mint/auth/mod.rs b/crates/cdk-common/src/database/mint/auth/mod.rs new file mode 100644 index 00000000..04a69f66 --- /dev/null +++ b/crates/cdk-common/src/database/mint/auth/mod.rs @@ -0,0 +1,72 @@ +//! Mint in memory database use std::collections::HashMap; + +use std::collections::HashMap; + +use async_trait::async_trait; +use cashu::{AuthRequired, ProtectedEndpoint}; + +use crate::database::Error; +use crate::mint::MintKeySetInfo; +use crate::nuts::nut07::State; +use crate::nuts::{AuthProof, BlindSignature, Id, PublicKey}; + +/// Mint Database trait +#[async_trait] +pub trait MintAuthDatabase { + /// Mint Database Error + type Err: Into + From; + /// Add Active Keyset + async fn set_active_keyset(&self, id: Id) -> Result<(), Self::Err>; + /// Get Active Keyset + async fn get_active_keyset_id(&self) -> Result, Self::Err>; + + /// Add [`MintKeySetInfo`] + async fn add_keyset_info(&self, keyset: MintKeySetInfo) -> Result<(), Self::Err>; + /// Get [`MintKeySetInfo`] + async fn get_keyset_info(&self, id: &Id) -> Result, Self::Err>; + /// Get [`MintKeySetInfo`]s + async fn get_keyset_infos(&self) -> Result, Self::Err>; + + /// Add spent [`Proofs`] + async fn add_proof(&self, proof: AuthProof) -> Result<(), Self::Err>; + /// Get [`Proofs`] state + async fn get_proofs_states(&self, ys: &[PublicKey]) -> Result>, Self::Err>; + /// Get [`Proofs`] state + async fn update_proof_state( + &self, + y: &PublicKey, + proofs_state: State, + ) -> Result, Self::Err>; + + /// Add [`BlindSignature`] + async fn add_blind_signatures( + &self, + blinded_messages: &[PublicKey], + blind_signatures: &[BlindSignature], + ) -> Result<(), Self::Err>; + /// Get [`BlindSignature`]s + async fn get_blind_signatures( + &self, + blinded_messages: &[PublicKey], + ) -> Result>, Self::Err>; + + /// Add protected endpoints + async fn add_protected_endpoints( + &self, + protected_endpoints: HashMap, + ) -> Result<(), Self::Err>; + /// Removed Protected endpoints + async fn remove_protected_endpoints( + &self, + protected_endpoints: Vec, + ) -> Result<(), Self::Err>; + /// Get auth for protected_endpoint + async fn get_auth_for_endpoint( + &self, + protected_endpoint: ProtectedEndpoint, + ) -> Result, Self::Err>; + /// Get protected endpoints + async fn get_auth_for_endpoints( + &self, + ) -> Result>, Self::Err>; +} diff --git a/crates/cdk-common/src/database/mint.rs b/crates/cdk-common/src/database/mint/mod.rs similarity index 98% rename from crates/cdk-common/src/database/mint.rs rename to crates/cdk-common/src/database/mint/mod.rs index a6cdd486..f5856f18 100644 --- a/crates/cdk-common/src/database/mint.rs +++ b/crates/cdk-common/src/database/mint/mod.rs @@ -14,6 +14,12 @@ use crate::nuts::{ Proofs, PublicKey, State, }; +#[cfg(feature = "auth")] +mod auth; + +#[cfg(feature = "auth")] +pub use auth::MintAuthDatabase; + /// Mint Database trait #[async_trait] pub trait Database { diff --git a/crates/cdk-common/src/database/mod.rs b/crates/cdk-common/src/database/mod.rs index e12b0484..64e6f79c 100644 --- a/crates/cdk-common/src/database/mod.rs +++ b/crates/cdk-common/src/database/mod.rs @@ -7,6 +7,8 @@ mod wallet; #[cfg(feature = "mint")] pub use mint::Database as MintDatabase; +#[cfg(all(feature = "mint", feature = "auth"))] +pub use mint::MintAuthDatabase; #[cfg(feature = "wallet")] pub use wallet::Database as WalletDatabase; @@ -25,6 +27,10 @@ pub enum Error { /// NUT02 Error #[error(transparent)] NUT02(#[from] crate::nuts::nut02::Error), + /// NUT22 Error + #[error(transparent)] + #[cfg(feature = "auth")] + NUT22(#[from] crate::nuts::nut22::Error), /// Serde Error #[error(transparent)] Serde(#[from] serde_json::Error), diff --git a/crates/cdk-common/src/error.rs b/crates/cdk-common/src/error.rs index 1364e2bf..1d8b6469 100644 --- a/crates/cdk-common/src/error.rs +++ b/crates/cdk-common/src/error.rs @@ -58,6 +58,36 @@ pub enum Error { /// Multi-Part Payment not supported for unit and method #[error("Multi-Part payment is not supported for unit `{0}` and method `{1}`")] MppUnitMethodNotSupported(CurrencyUnit, PaymentMethod), + /// Clear Auth Required + #[error("Clear Auth Required")] + ClearAuthRequired, + /// Blind Auth Required + #[error("Blind Auth Required")] + BlindAuthRequired, + /// Clear Auth Failed + #[error("Clear Auth Failed")] + ClearAuthFailed, + /// Blind Auth Failed + #[error("Blind Auth Failed")] + BlindAuthFailed, + /// Auth settings undefined + #[error("Auth settings undefined")] + AuthSettingsUndefined, + /// Mint time outside of tolerance + #[error("Mint time outside of tolerance")] + MintTimeExceedsTolerance, + /// Insufficient blind auth tokens + #[error("Insufficient blind auth tokens, must reauth")] + InsufficientBlindAuthTokens, + /// Auth localstore undefined + #[error("Auth localstore undefined")] + AuthLocalstoreUndefined, + /// Wallet cat not set + #[error("Wallet cat not set")] + CatNotSet, + /// Could not get mint info + #[error("Could not get mint info")] + CouldNotGetMintInfo, // Mint Errors /// Minting is disabled @@ -126,6 +156,9 @@ pub enum Error { /// Internal Error #[error("Internal Error")] Internal, + /// Oidc config not set + #[error("Oidc client not set")] + OidcNotSet, // Wallet Errors /// P2PK spending conditions not met @@ -156,6 +189,9 @@ pub enum Error { /// Invalid DLEQ proof #[error("Could not verify DLEQ proof")] CouldNotVerifyDleq, + /// Dleq Proof not provided for signature + #[error("Dleq proof not provided for signature")] + DleqProofNotProvided, /// Incorrect Mint /// Token does not match wallet mint #[error("Token does not match wallet mint")] @@ -213,7 +249,7 @@ pub enum Error { /// Http transport error #[error("Http transport error: {0}")] HttpError(String), - + #[cfg(feature = "wallet")] // Crate error conversions /// Cashu Url Error #[error(transparent)] @@ -264,6 +300,12 @@ pub enum Error { /// NUT20 Error #[error(transparent)] NUT20(#[from] crate::nuts::nut20::Error), + /// NUTXX Error + #[error(transparent)] + NUT21(#[from] crate::nuts::nut21::Error), + /// NUTXX1 Error + #[error(transparent)] + NUT22(#[from] crate::nuts::nut22::Error), /// Database Error #[error(transparent)] Database(#[from] crate::database::Error), @@ -397,6 +439,26 @@ impl From for ErrorResponse { error: Some(err.to_string()), detail: None, }, + Error::ClearAuthRequired => ErrorResponse { + code: ErrorCode::ClearAuthRequired, + error: None, + detail: None, + }, + Error::ClearAuthFailed => ErrorResponse { + code: ErrorCode::ClearAuthFailed, + error: None, + detail: None, + }, + Error::BlindAuthRequired => ErrorResponse { + code: ErrorCode::BlindAuthRequired, + error: None, + detail: None, + }, + Error::BlindAuthFailed => ErrorResponse { + code: ErrorCode::BlindAuthFailed, + error: None, + detail: None, + }, Error::NUT20(err) => ErrorResponse { code: ErrorCode::WitnessMissingOrInvalid, error: Some(err.to_string()), @@ -456,6 +518,8 @@ impl From for Error { ErrorCode::DuplicateOutputs => Self::DuplicateOutputs, ErrorCode::MultipleUnits => Self::MultipleUnits, ErrorCode::UnitMismatch => Self::UnitMismatch, + ErrorCode::ClearAuthRequired => Self::ClearAuthRequired, + ErrorCode::BlindAuthRequired => Self::BlindAuthRequired, _ => Self::UnknownErrorResponse(err.to_string()), } } @@ -507,6 +571,14 @@ pub enum ErrorCode { MultipleUnits, /// Input unit does not match output UnitMismatch, + /// Clear Auth Required + ClearAuthRequired, + /// Clear Auth Failed + ClearAuthFailed, + /// Blind Auth Required + BlindAuthRequired, + /// Blind Auth Failed + BlindAuthFailed, /// Unknown error code Unknown(u16), } @@ -536,6 +608,10 @@ impl ErrorCode { 20006 => Self::InvoiceAlreadyPaid, 20007 => Self::QuoteExpired, 20008 => Self::WitnessMissingOrInvalid, + 30001 => Self::ClearAuthRequired, + 30002 => Self::ClearAuthFailed, + 31001 => Self::BlindAuthRequired, + 31002 => Self::BlindAuthFailed, _ => Self::Unknown(code), } } @@ -564,6 +640,10 @@ impl ErrorCode { Self::InvoiceAlreadyPaid => 20006, Self::QuoteExpired => 20007, Self::WitnessMissingOrInvalid => 20008, + Self::ClearAuthRequired => 30001, + Self::ClearAuthFailed => 30002, + Self::BlindAuthRequired => 31001, + Self::BlindAuthFailed => 31002, Self::Unknown(code) => *code, } } diff --git a/crates/cdk-common/src/lib.rs b/crates/cdk-common/src/lib.rs index 1f2e4577..a93a003e 100644 --- a/crates/cdk-common/src/lib.rs +++ b/crates/cdk-common/src/lib.rs @@ -16,7 +16,6 @@ pub mod payment; pub mod pub_sub; pub mod subscription; pub mod ws; - // re-exporting external crates pub use bitcoin; pub use cashu::amount::{self, Amount}; @@ -25,3 +24,4 @@ pub use cashu::nuts::{self, *}; #[cfg(feature = "wallet")] pub use cashu::wallet; pub use cashu::{dhke, ensure_cdk, mint_url, secret, util, SECP256K1}; +pub use error::Error; diff --git a/crates/cdk-integration-tests/Cargo.toml b/crates/cdk-integration-tests/Cargo.toml index e3d821b1..c3375377 100644 --- a/crates/cdk-integration-tests/Cargo.toml +++ b/crates/cdk-integration-tests/Cargo.toml @@ -20,7 +20,7 @@ rand.workspace = true bip39 = { workspace = true, features = ["rand"] } anyhow.workspace = true cashu = { path = "../cashu", features = ["mint", "wallet"] } -cdk = { path = "../cdk", features = ["mint", "wallet"] } +cdk = { path = "../cdk", features = ["mint", "wallet", "auth"] } cdk-cln = { path = "../cdk-cln" } cdk-lnd = { path = "../cdk-lnd" } cdk-axum = { path = "../cdk-axum" } @@ -42,6 +42,7 @@ tracing-subscriber.workspace = true tokio-tungstenite.workspace = true tower-http = { workspace = true, features = ["cors"] } tower-service = "0.3.3" +reqwest.workspace = true [target.'cfg(not(target_arch = "wasm32"))'.dependencies] tokio.workspace = true diff --git a/crates/cdk-integration-tests/src/init_auth_mint.rs b/crates/cdk-integration-tests/src/init_auth_mint.rs new file mode 100644 index 00000000..a899fa69 --- /dev/null +++ b/crates/cdk-integration-tests/src/init_auth_mint.rs @@ -0,0 +1,102 @@ +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; + +use anyhow::Result; +use bip39::Mnemonic; +use cashu::{AuthRequired, Method, ProtectedEndpoint, RoutePath}; +use cdk::cdk_database::{self, MintAuthDatabase, MintDatabase}; +use cdk::mint::{MintBuilder, MintMeltLimits}; +use cdk::nuts::{CurrencyUnit, PaymentMethod}; +use cdk::types::FeeReserve; +use cdk::wallet::AuthWallet; +use cdk_fake_wallet::FakeWallet; + +pub async fn start_fake_mint_with_auth( + _addr: &str, + _port: u16, + openid_discovery: String, + database: D, + auth_database: A, +) -> Result<()> +where + D: MintDatabase + Send + Sync + 'static, + A: MintAuthDatabase + Send + Sync + 'static, +{ + let fee_reserve = FeeReserve { + min_fee_reserve: 1.into(), + percent_fee_reserve: 1.0, + }; + + let fake_wallet = FakeWallet::new(fee_reserve, HashMap::default(), HashSet::default(), 0); + + let mut mint_builder = MintBuilder::new(); + + mint_builder = mint_builder.with_localstore(Arc::new(database)); + + mint_builder = mint_builder + .add_ln_backend( + CurrencyUnit::Sat, + PaymentMethod::Bolt11, + MintMeltLimits::new(1, 300), + Arc::new(fake_wallet), + ) + .await?; + + mint_builder = + mint_builder.set_clear_auth_settings(openid_discovery, "cashu-client".to_string()); + + mint_builder = mint_builder.set_blind_auth_settings(50); + + let blind_auth_endpoints = vec![ + ProtectedEndpoint::new(Method::Post, RoutePath::MintQuoteBolt11), + ProtectedEndpoint::new(Method::Post, RoutePath::MintBolt11), + ProtectedEndpoint::new(Method::Get, RoutePath::MintQuoteBolt11), + ProtectedEndpoint::new(Method::Post, RoutePath::MeltQuoteBolt11), + ProtectedEndpoint::new(Method::Get, RoutePath::MeltQuoteBolt11), + ProtectedEndpoint::new(Method::Post, RoutePath::MeltBolt11), + ProtectedEndpoint::new(Method::Post, RoutePath::Swap), + ProtectedEndpoint::new(Method::Post, RoutePath::Checkstate), + ProtectedEndpoint::new(Method::Post, RoutePath::Restore), + ]; + + let blind_auth_endpoints = + blind_auth_endpoints + .into_iter() + .fold(HashMap::new(), |mut acc, e| { + acc.insert(e, AuthRequired::Blind); + acc + }); + + auth_database + .add_protected_endpoints(blind_auth_endpoints) + .await?; + + let mut clear_auth_endpoint = HashMap::new(); + clear_auth_endpoint.insert( + ProtectedEndpoint::new(Method::Post, RoutePath::MintBlindAuth), + AuthRequired::Clear, + ); + + auth_database + .add_protected_endpoints(clear_auth_endpoint) + .await?; + + mint_builder = mint_builder.with_auth_localstore(Arc::new(auth_database)); + + let mnemonic = Mnemonic::generate(12)?; + + mint_builder = mint_builder + .with_description("fake test mint".to_string()) + .with_seed(mnemonic.to_seed_normalized("").to_vec()); + + let _mint = mint_builder.build().await?; + + todo!("Need to start this a cdk mintd keeping as ref for now"); +} + +pub async fn top_up_blind_auth_proofs(auth_wallet: Arc, count: u64) { + let _proofs = auth_wallet + .mint_blind_auth(count.into()) + .await + .expect("could not mint blind auth"); +} diff --git a/crates/cdk-integration-tests/src/init_pure_tests.rs b/crates/cdk-integration-tests/src/init_pure_tests.rs index ac1f8f90..99f2a521 100644 --- a/crates/cdk-integration-tests/src/init_pure_tests.rs +++ b/crates/cdk-integration-tests/src/init_pure_tests.rs @@ -18,11 +18,10 @@ use cdk::nuts::{ }; use cdk::types::{FeeReserve, QuoteTTL}; use cdk::util::unix_time; -use cdk::wallet::client::MintConnector; -use cdk::wallet::Wallet; +use cdk::wallet::{AuthWallet, MintConnector, Wallet, WalletBuilder}; use cdk::{Amount, Error, Mint}; use cdk_fake_wallet::FakeWallet; -use tokio::sync::{Mutex, Notify}; +use tokio::sync::{Mutex, Notify, RwLock}; use tracing_subscriber::EnvFilter; use uuid::Uuid; @@ -30,11 +29,15 @@ use crate::wait_for_mint_to_be_paid; pub struct DirectMintConnection { pub mint: Arc, + auth_wallet: Arc>>, } impl DirectMintConnection { pub fn new(mint: Arc) -> Self { - Self { mint } + Self { + mint, + auth_wallet: Arc::new(RwLock::new(None)), + } } } @@ -141,6 +144,18 @@ impl MintConnector for DirectMintConnection { async fn post_restore(&self, request: RestoreRequest) -> Result { self.mint.restore(request).await } + + /// Get the auth wallet for the client + async fn get_auth_wallet(&self) -> Option { + self.auth_wallet.read().await.clone() + } + + /// Set auth wallet on client + async fn set_auth_wallet(&self, wallet: Option) { + let mut auth_wallet = self.auth_wallet.write().await; + + *auth_wallet = wallet; + } } pub fn setup_tracing() { @@ -232,9 +247,14 @@ async fn create_test_wallet_for_mint(mint: Arc) -> Result { let seed = Mnemonic::generate(12)?.to_seed_normalized(""); let unit = CurrencyUnit::Sat; let localstore = cdk_sqlite::wallet::memory::empty().await?; - let mut wallet = Wallet::new(mint_url, unit, Arc::new(localstore), &seed, None)?; - wallet.set_client(connector); + let wallet = WalletBuilder::new() + .mint_url(mint_url.parse().unwrap()) + .unit(unit) + .localstore(Arc::new(localstore)) + .seed(&seed) + .client(connector) + .build()?; Ok(wallet) } diff --git a/crates/cdk-integration-tests/src/lib.rs b/crates/cdk-integration-tests/src/lib.rs index 6a1e9adf..5485ef39 100644 --- a/crates/cdk-integration-tests/src/lib.rs +++ b/crates/cdk-integration-tests/src/lib.rs @@ -2,32 +2,29 @@ use std::sync::Arc; use anyhow::{anyhow, bail, Result}; use cdk::amount::{Amount, SplitTarget}; -use cdk::nuts::nut00::ProofsMethods; use cdk::nuts::{MintQuoteState, NotificationPayload, State}; use cdk::wallet::WalletSubscription; use cdk::Wallet; use tokio::time::{sleep, timeout, Duration}; +pub mod init_auth_mint; pub mod init_pure_tests; pub mod init_regtest; -pub async fn wallet_mint( - wallet: Arc, - amount: Amount, - split_target: SplitTarget, - description: Option, -) -> Result<()> { - let quote = wallet.mint_quote(amount, description).await?; +pub async fn fund_wallet(wallet: Arc, amount: Amount) { + let quote = wallet + .mint_quote(amount, None) + .await + .expect("Could not get mint quote"); - wait_for_mint_to_be_paid(&wallet, "e.id, 60).await?; + wait_for_mint_to_be_paid(&wallet, "e.id, 60) + .await + .expect("Waiting for mint failed"); - let proofs = wallet.mint("e.id, split_target, None).await?; - - let receive_amount = proofs.total_amount()?; - - println!("Minted: {}", receive_amount); - - Ok(()) + let _proofs = wallet + .mint("e.id, SplitTarget::default(), None) + .await + .expect("Could not mint"); } // Get all pending from wallet and attempt to swap diff --git a/crates/cdk-integration-tests/src/mock_oauth/mod.rs b/crates/cdk-integration-tests/src/mock_oauth/mod.rs new file mode 100644 index 00000000..409bf374 --- /dev/null +++ b/crates/cdk-integration-tests/src/mock_oauth/mod.rs @@ -0,0 +1,39 @@ +use axum::response::{IntoResponse, Response, Result}; +use axum::routing::get; +use axum::{Json, Router}; +use cdk::oidc_client::OidcConfig; +use jsonwebtoken::jwk::{AlgorithmParameters, Jwk, JwkSet}; +use serde_json::{json, Value}; + +async fn crate_mock_oauth() -> Router { + let router = Router::new() + .route("/config", get(handler_get_config)) + .route("/token", get(handler_get_token)) + .route("/jwks", get(handler_get_jwkset)); + router +} + +async fn handler_get_config() -> Result> { + Ok(Json(OidcConfig { + jwks_uri: "/jwks".to_string(), + issuer: "127.0.0.1".to_string(), + token_endpoint: "/token".to_string(), + })) +} + +async fn handler_get_jwkset() -> Result> { + let jwk:Jwk = serde_json::from_value(json!({ + "kty": "RSA", + "n": "yRE6rHuNR0QbHO3H3Kt2pOKGVhQqGZXInOduQNxXzuKlvQTLUTv4l4sggh5_CYYi_cvI-SXVT9kPWSKXxJXBXd_4LkvcPuUakBoAkfh-eiFVMh2VrUyWyj3MFl0HTVF9KwRXLAcwkREiS3npThHRyIxuy0ZMeZfxVL5arMhw1SRELB8HoGfG_AtH89BIE9jDBHZ9dLelK9a184zAf8LwoPLxvJb3Il5nncqPcSfKDDodMFBIMc4lQzDKL5gvmiXLXB1AGLm8KBjfE8s3L5xqi-yUod-j8MtvIj812dkS4QMiRVN_by2h3ZY8LYVGrqZXZTcgn2ujn8uKjXLZVD5TdQ", + "e": "AQAB", + "kid": "rsa01", + "alg": "RS256", + "use": "sig" + })).unwrap(); + + Ok(Json(JwkSet { keys: vec![jwk] })) +} + +async fn handler_get_token() -> Result> { + Ok(Json(json!({"access_token": ""}))) +} diff --git a/crates/cdk-integration-tests/tests/fake_auth.rs b/crates/cdk-integration-tests/tests/fake_auth.rs new file mode 100644 index 00000000..bf0f7e9d --- /dev/null +++ b/crates/cdk-integration-tests/tests/fake_auth.rs @@ -0,0 +1,866 @@ +use std::env; +use std::str::FromStr; +use std::sync::Arc; + +use bip39::Mnemonic; +use cashu::{MintAuthRequest, MintInfo}; +use cdk::amount::{Amount, SplitTarget}; +use cdk::mint_url::MintUrl; +use cdk::nuts::nut00::ProofsMethods; +use cdk::nuts::{ + AuthProof, AuthToken, BlindAuthToken, CheckStateRequest, CurrencyUnit, MeltBolt11Request, + MeltQuoteBolt11Request, MeltQuoteState, MintBolt11Request, MintQuoteBolt11Request, + RestoreRequest, State, SwapRequest, +}; +use cdk::wallet::{AuthHttpClient, AuthMintConnector, HttpClient, MintConnector, WalletBuilder}; +use cdk::{Error, OidcClient}; +use cdk_fake_wallet::create_fake_invoice; +use cdk_integration_tests::{fund_wallet, wait_for_mint_to_be_paid}; +use cdk_sqlite::wallet::memory; + +const MINT_URL: &str = "http://127.0.0.1:8087"; +const ENV_OIDC_USER: &str = "CDK_TEST_OIDC_USER"; +const ENV_OIDC_PASSWORD: &str = "CDK_TEST_OIDC_PASSWORD"; + +fn get_oidc_credentials() -> (String, String) { + let user = env::var(ENV_OIDC_USER).unwrap_or_else(|_| "test".to_string()); + let password = env::var(ENV_OIDC_PASSWORD).unwrap_or_else(|_| "test".to_string()); + (user, password) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_invalid_credentials() { + let db = Arc::new(memory::empty().await.unwrap()); + + let wallet = WalletBuilder::new() + .mint_url(MintUrl::from_str(MINT_URL).expect("Valid mint url")) + .unit(CurrencyUnit::Sat) + .localstore(db.clone()) + .seed(&Mnemonic::generate(12).unwrap().to_seed_normalized("")) + .build() + .expect("Wallet"); + + let mint_info = wallet + .get_mint_info() + .await + .expect("mint info") + .expect("could not get mint info"); + + // Try to get a token with invalid credentials + let token_result = + get_custom_access_token(&mint_info, "invalid_user", "invalid_password").await; + + // Should fail with an error + assert!( + token_result.is_err(), + "Expected authentication to fail with invalid credentials" + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_quote_status_without_auth() { + let client = HttpClient::new(MintUrl::from_str(MINT_URL).expect("Valid mint url"), None); + + // Test mint quote status + { + let quote_res = client + .get_mint_quote_status("123e4567-e89b-12d3-a456-426614174000") + .await; + + assert!( + matches!(quote_res, Err(Error::BlindAuthRequired)), + "Expected AuthRequired error, got {:?}", + quote_res + ); + } + + // Test melt quote status + { + let quote_res = client + .get_melt_quote_status("123e4567-e89b-12d3-a456-426614174000") + .await; + + assert!( + matches!(quote_res, Err(Error::BlindAuthRequired)), + "Expected AuthRequired error, got {:?}", + quote_res + ); + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_mint_without_auth() { + let client = HttpClient::new(MintUrl::from_str(MINT_URL).expect("Valid mint url"), None); + { + let request = MintQuoteBolt11Request { + unit: CurrencyUnit::Sat, + amount: 10.into(), + description: None, + pubkey: None, + }; + + let quote_res = client.post_mint_quote(request).await; + + assert!( + matches!(quote_res, Err(Error::BlindAuthRequired)), + "Expected AuthRequired error, got {:?}", + quote_res + ); + } + + { + let request = MintBolt11Request { + quote: "123e4567-e89b-12d3-a456-426614174000".to_string(), + outputs: vec![], + signature: None, + }; + + let mint_res = client.post_mint(request).await; + + assert!( + matches!(mint_res, Err(Error::BlindAuthRequired)), + "Expected AuthRequired error, got {:?}", + mint_res + ); + } + + { + let mint_res = client + .get_mint_quote_status("123e4567-e89b-12d3-a456-426614174000") + .await; + + assert!( + matches!(mint_res, Err(Error::BlindAuthRequired)), + "Expected AuthRequired error, got {:?}", + mint_res + ); + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_mint_bat_without_cat() { + let client = AuthHttpClient::new(MintUrl::from_str(MINT_URL).expect("valid mint url"), None); + + let res = client + .post_mint_blind_auth(MintAuthRequest { outputs: vec![] }) + .await; + + assert!( + matches!(res, Err(Error::ClearAuthRequired)), + "Expected AuthRequired error, got {:?}", + res + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_swap_without_auth() { + let client = HttpClient::new(MintUrl::from_str(MINT_URL).expect("Valid mint url"), None); + + let request = SwapRequest { + inputs: vec![], + outputs: vec![], + }; + + let quote_res = client.post_swap(request).await; + + assert!( + matches!(quote_res, Err(Error::BlindAuthRequired)), + "Expected AuthRequired error, got {:?}", + quote_res + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_melt_without_auth() { + let client = HttpClient::new(MintUrl::from_str(MINT_URL).expect("Valid mint url"), None); + + // Test melt quote request + { + let request = MeltQuoteBolt11Request { + request: create_fake_invoice(100, "".to_string()), + unit: CurrencyUnit::Sat, + options: None, + }; + + let quote_res = client.post_melt_quote(request).await; + + assert!( + matches!(quote_res, Err(Error::BlindAuthRequired)), + "Expected AuthRequired error, got {:?}", + quote_res + ); + } + + // Test melt quote + { + let request = MeltQuoteBolt11Request { + request: create_fake_invoice(100, "".to_string()), + unit: CurrencyUnit::Sat, + options: None, + }; + + let quote_res = client.post_melt_quote(request).await; + + assert!( + matches!(quote_res, Err(Error::BlindAuthRequired)), + "Expected AuthRequired error, got {:?}", + quote_res + ); + } + + // Test melt + { + let request = MeltBolt11Request { + inputs: vec![], + outputs: None, + quote: "123e4567-e89b-12d3-a456-426614174000".to_string(), + }; + + let melt_res = client.post_melt(request).await; + + assert!( + matches!(melt_res, Err(Error::BlindAuthRequired)), + "Expected AuthRequired error, got {:?}", + melt_res + ); + } + + // Check melt quote state + { + let melt_res = client + .get_melt_quote_status("123e4567-e89b-12d3-a456-426614174000") + .await; + + assert!( + matches!(melt_res, Err(Error::BlindAuthRequired)), + "Expected AuthRequired error, got {:?}", + melt_res + ); + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_check_without_auth() { + let client = HttpClient::new(MintUrl::from_str(MINT_URL).expect("Valid mint url"), None); + + let request = CheckStateRequest { ys: vec![] }; + + let quote_res = client.post_check_state(request).await; + + assert!( + matches!(quote_res, Err(Error::BlindAuthRequired)), + "Expected AuthRequired error, got {:?}", + quote_res + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_restore_without_auth() { + let client = HttpClient::new(MintUrl::from_str(MINT_URL).expect("Valid mint url"), None); + + let request = RestoreRequest { outputs: vec![] }; + + let restore_res = client.post_restore(request).await; + + assert!( + matches!(restore_res, Err(Error::BlindAuthRequired)), + "Expected AuthRequired error, got {:?}", + restore_res + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_mint_blind_auth() { + let db = Arc::new(memory::empty().await.unwrap()); + + let wallet = WalletBuilder::new() + .mint_url(MintUrl::from_str(MINT_URL).expect("Valid mint url")) + .unit(CurrencyUnit::Sat) + .localstore(db.clone()) + .seed(&Mnemonic::generate(12).unwrap().to_seed_normalized("")) + .build() + .expect("Wallet"); + let mint_info = wallet.get_mint_info().await.unwrap().unwrap(); + + let (access_token, _) = get_access_token(&mint_info).await; + + wallet.set_cat(access_token).await.unwrap(); + + wallet + .mint_blind_auth(10.into()) + .await + .expect("Could not mint blind auth"); + + let proofs = wallet + .get_unspent_auth_proofs() + .await + .expect("Could not get auth proofs"); + + assert!(proofs.len() == 10) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_mint_with_auth() { + let db = Arc::new(memory::empty().await.unwrap()); + + let wallet = WalletBuilder::new() + .mint_url(MintUrl::from_str(MINT_URL).expect("Valid mint url")) + .unit(CurrencyUnit::Sat) + .localstore(db.clone()) + .seed(&Mnemonic::generate(12).unwrap().to_seed_normalized("")) + .build() + .expect("Wallet"); + + let mint_info = wallet + .get_mint_info() + .await + .expect("mint info") + .expect("could not get mint info"); + + let (access_token, _) = get_access_token(&mint_info).await; + + println!("st{}", access_token); + + wallet.set_cat(access_token).await.unwrap(); + + wallet + .mint_blind_auth(10.into()) + .await + .expect("Could not mint blind auth"); + + let wallet = Arc::new(wallet); + + let mint_amount: Amount = 100.into(); + + let mint_quote = wallet + .mint_quote(mint_amount, None) + .await + .expect("failed to get mint quote"); + + wait_for_mint_to_be_paid(&wallet, &mint_quote.id, 60) + .await + .expect("failed to wait for payment"); + + let proofs = wallet + .mint(&mint_quote.id, SplitTarget::default(), None) + .await + .expect("could not mint"); + + assert!(proofs.total_amount().expect("Could not get proofs amount") == mint_amount); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_swap_with_auth() { + let db = Arc::new(memory::empty().await.unwrap()); + + let wallet = WalletBuilder::new() + .mint_url(MintUrl::from_str(MINT_URL).expect("Valid mint url")) + .unit(CurrencyUnit::Sat) + .localstore(db.clone()) + .seed(&Mnemonic::generate(12).unwrap().to_seed_normalized("")) + .build() + .expect("Wallet"); + let mint_info = wallet.get_mint_info().await.unwrap().unwrap(); + let (access_token, _) = get_access_token(&mint_info).await; + + wallet.set_cat(access_token).await.unwrap(); + + let wallet = Arc::new(wallet); + + wallet.mint_blind_auth(10.into()).await.unwrap(); + + fund_wallet(wallet.clone(), 100.into()).await; + + let proofs = wallet + .get_unspent_proofs() + .await + .expect("Could not get proofs"); + + let swapped_proofs = wallet + .swap( + Some(proofs.total_amount().unwrap()), + SplitTarget::default(), + proofs.clone(), + None, + false, + ) + .await + .expect("Could not swap") + .expect("Could not swap"); + + let check_spent = wallet + .check_proofs_spent(proofs.clone()) + .await + .expect("Could not check proofs"); + + for state in check_spent { + if state.state != State::Spent { + panic!("Input proofs should be spent"); + } + } + + assert!(swapped_proofs.total_amount().unwrap() == proofs.total_amount().unwrap()) +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_melt_with_auth() { + let db = Arc::new(memory::empty().await.unwrap()); + + let wallet = WalletBuilder::new() + .mint_url(MintUrl::from_str(MINT_URL).expect("Valid mint url")) + .unit(CurrencyUnit::Sat) + .localstore(db.clone()) + .seed(&Mnemonic::generate(12).unwrap().to_seed_normalized("")) + .build() + .expect("Wallet"); + + let mint_info = wallet + .get_mint_info() + .await + .expect("Mint info not found") + .expect("Mint info not found"); + + let (access_token, _) = get_access_token(&mint_info).await; + + wallet.set_cat(access_token).await.unwrap(); + + let wallet = Arc::new(wallet); + + wallet.mint_blind_auth(10.into()).await.unwrap(); + + fund_wallet(wallet.clone(), 100.into()).await; + + let bolt11 = create_fake_invoice(2_000, "".to_string()); + + let melt_quote = wallet + .melt_quote(bolt11.to_string(), None) + .await + .expect("Could not get melt quote"); + + let after_melt = wallet.melt(&melt_quote.id).await.expect("Could not melt"); + + assert!(after_melt.state == MeltQuoteState::Paid); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_mint_auth_over_max() { + let db = Arc::new(memory::empty().await.unwrap()); + + let wallet = WalletBuilder::new() + .mint_url(MintUrl::from_str(MINT_URL).expect("Valid mint url")) + .unit(CurrencyUnit::Sat) + .localstore(db.clone()) + .seed(&Mnemonic::generate(12).unwrap().to_seed_normalized("")) + .build() + .expect("Wallet"); + + let wallet = Arc::new(wallet); + + let mint_info = wallet + .get_mint_info() + .await + .expect("Mint info not found") + .expect("Mint info not found"); + + let (access_token, _) = get_access_token(&mint_info).await; + + wallet.set_cat(access_token).await.unwrap(); + + let auth_proofs = wallet + .mint_blind_auth((mint_info.nuts.nut22.expect("Auth enabled").bat_max_mint + 1).into()) + .await; + + assert!( + matches!( + auth_proofs, + Err(Error::AmountOutofLimitRange( + Amount::ZERO, + Amount::ZERO, + Amount::ZERO, + )) + ), + "Expected amount out of limit error, got {:?}", + auth_proofs + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_reuse_auth_proof() { + let db = Arc::new(memory::empty().await.unwrap()); + + let wallet = WalletBuilder::new() + .mint_url(MintUrl::from_str(MINT_URL).expect("Valid mint url")) + .unit(CurrencyUnit::Sat) + .localstore(db.clone()) + .seed(&Mnemonic::generate(12).unwrap().to_seed_normalized("")) + .build() + .expect("Wallet"); + let mint_info = wallet.get_mint_info().await.unwrap().unwrap(); + + let (access_token, _) = get_access_token(&mint_info).await; + + wallet.set_cat(access_token).await.unwrap(); + + wallet.mint_blind_auth(1.into()).await.unwrap(); + + let proofs = wallet + .localstore + .get_proofs(None, Some(CurrencyUnit::Auth), None, None) + .await + .unwrap(); + + assert!(proofs.len() == 1); + + { + let quote = wallet + .mint_quote(10.into(), None) + .await + .expect("Quote should be allowed"); + + assert!(quote.amount == 10.into()); + } + + wallet + .localstore + .update_proofs(proofs, vec![]) + .await + .unwrap(); + + { + let quote_res = wallet.mint_quote(10.into(), None).await; + assert!( + matches!(quote_res, Err(Error::TokenAlreadySpent)), + "Expected AuthRequired error, got {:?}", + quote_res + ); + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_melt_with_invalid_auth() { + let db = Arc::new(memory::empty().await.unwrap()); + + let wallet = WalletBuilder::new() + .mint_url(MintUrl::from_str(MINT_URL).expect("Valid mint url")) + .unit(CurrencyUnit::Sat) + .localstore(db.clone()) + .seed(&Mnemonic::generate(12).unwrap().to_seed_normalized("")) + .build() + .expect("Wallet"); + let mint_info = wallet.get_mint_info().await.unwrap().unwrap(); + + let (access_token, _) = get_access_token(&mint_info).await; + + wallet.set_cat(access_token).await.unwrap(); + + wallet.mint_blind_auth(10.into()).await.unwrap(); + + fund_wallet(Arc::new(wallet.clone()), 1.into()).await; + + let proofs = wallet + .get_unspent_proofs() + .await + .expect("wallet has proofs"); + + println!("{:#?}", proofs); + let proof = proofs.first().expect("wallet has one proof"); + + let client = HttpClient::new(MintUrl::from_str(MINT_URL).expect("Valid mint url"), None); + { + let invalid_auth_proof = AuthProof { + keyset_id: proof.keyset_id, + secret: proof.secret.clone(), + c: proof.c, + dleq: proof.dleq.clone().unwrap(), + }; + + let _auth_token = AuthToken::BlindAuth(BlindAuthToken::new(invalid_auth_proof)); + + let request = MintQuoteBolt11Request { + unit: CurrencyUnit::Sat, + amount: 10.into(), + description: None, + pubkey: None, + }; + + let quote_res = client.post_mint_quote(request).await; + + assert!( + matches!(quote_res, Err(Error::BlindAuthRequired)), + "Expected AuthRequired error, got {:?}", + quote_res + ); + } + + { + let (access_token, _) = get_access_token(&mint_info).await; + + wallet.set_cat(access_token).await.unwrap(); + } +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_refresh_access_token() { + let db = Arc::new(memory::empty().await.unwrap()); + + let wallet = WalletBuilder::new() + .mint_url(MintUrl::from_str(MINT_URL).expect("Valid mint url")) + .unit(CurrencyUnit::Sat) + .localstore(db.clone()) + .seed(&Mnemonic::generate(12).unwrap().to_seed_normalized("")) + .build() + .expect("Wallet"); + + let mint_info = wallet + .get_mint_info() + .await + .expect("mint info") + .expect("could not get mint info"); + + let (access_token, refresh_token) = get_access_token(&mint_info).await; + + // Set the initial access token and refresh token + wallet.set_cat(access_token.clone()).await.unwrap(); + wallet + .set_refresh_token(refresh_token.clone()) + .await + .unwrap(); + + // Mint some blind auth tokens with the initial access token + wallet.mint_blind_auth(5.into()).await.unwrap(); + + // Refresh the access token + wallet.refresh_access_token().await.unwrap(); + + // Verify we can still perform operations with the refreshed token + let mint_amount: Amount = 10.into(); + + // Try to mint more blind auth tokens with the refreshed token + let auth_proofs = wallet.mint_blind_auth(5.into()).await.unwrap(); + assert_eq!(auth_proofs.len(), 5); + + let total_auth_proofs = wallet.get_unspent_auth_proofs().await.unwrap(); + assert_eq!(total_auth_proofs.len(), 10); // 5 from before refresh + 5 after refresh + + // Try to get a mint quote with the refreshed token + let mint_quote = wallet + .mint_quote(mint_amount, None) + .await + .expect("failed to get mint quote with refreshed token"); + + assert_eq!(mint_quote.amount, mint_amount); + + // Verify the total number of auth tokens + let total_auth_proofs = wallet.get_unspent_auth_proofs().await.unwrap(); + assert_eq!(total_auth_proofs.len(), 9); // 5 from before refresh + 5 after refresh - 1 for the quote +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_invalid_refresh_token() { + let db = Arc::new(memory::empty().await.unwrap()); + + let wallet = WalletBuilder::new() + .mint_url(MintUrl::from_str(MINT_URL).expect("Valid mint url")) + .unit(CurrencyUnit::Sat) + .localstore(db.clone()) + .seed(&Mnemonic::generate(12).unwrap().to_seed_normalized("")) + .build() + .expect("Wallet"); + + let mint_info = wallet + .get_mint_info() + .await + .expect("mint info") + .expect("could not get mint info"); + + let (access_token, _) = get_access_token(&mint_info).await; + + // Set the initial access token + wallet.set_cat(access_token.clone()).await.unwrap(); + + // Set an invalid refresh token + wallet + .set_refresh_token("invalid_refresh_token".to_string()) + .await + .unwrap(); + + // Attempt to refresh the access token with an invalid refresh token + let refresh_result = wallet.refresh_access_token().await; + + // Should fail with an error + assert!(refresh_result.is_err(), "Expected refresh token error"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 1)] +async fn test_auth_token_spending_order() { + let db = Arc::new(memory::empty().await.unwrap()); + + let wallet = WalletBuilder::new() + .mint_url(MintUrl::from_str(MINT_URL).expect("Valid mint url")) + .unit(CurrencyUnit::Sat) + .localstore(db.clone()) + .seed(&Mnemonic::generate(12).unwrap().to_seed_normalized("")) + .build() + .expect("Wallet"); + + let mint_info = wallet + .get_mint_info() + .await + .expect("mint info") + .expect("could not get mint info"); + + let (access_token, _) = get_access_token(&mint_info).await; + + wallet.set_cat(access_token).await.unwrap(); + + // Mint auth tokens in two batches to test ordering + wallet.mint_blind_auth(2.into()).await.unwrap(); + + // Get the first batch of auth proofs + let first_batch = wallet.get_unspent_auth_proofs().await.unwrap(); + assert_eq!(first_batch.len(), 2); + + // Mint a second batch + wallet.mint_blind_auth(3.into()).await.unwrap(); + + // Get all auth proofs + let all_proofs = wallet.get_unspent_auth_proofs().await.unwrap(); + assert_eq!(all_proofs.len(), 5); + + // Use tokens and verify they're used in the expected order (FIFO) + for i in 0..3 { + let mint_quote = wallet + .mint_quote(10.into(), None) + .await + .expect("failed to get mint quote"); + + assert_eq!(mint_quote.amount, 10.into()); + + // Check remaining tokens after each operation + let remaining = wallet.get_unspent_auth_proofs().await.unwrap(); + assert_eq!( + remaining.len(), + 5 - (i + 1), + "Expected {} remaining auth tokens after {} operations", + 5 - (i + 1), + i + 1 + ); + } +} + +async fn get_access_token(mint_info: &MintInfo) -> (String, String) { + let openid_discovery = mint_info + .nuts + .nut21 + .clone() + .expect("Nutxx defined") + .openid_discovery; + + let oidc_client = OidcClient::new(openid_discovery); + + // Get the token endpoint from the OIDC configuration + let token_url = oidc_client + .get_oidc_config() + .await + .expect("Failed to get OIDC config") + .token_endpoint; + + // Create the request parameters + let (user, password) = get_oidc_credentials(); + let params = [ + ("grant_type", "password"), + ("client_id", "cashu-client"), + ("username", &user), + ("password", &password), + ]; + + // Make the token request directly + let client = reqwest::Client::new(); + let response = client + .post(token_url) + .form(¶ms) + .send() + .await + .expect("Failed to send token request"); + + let token_response: serde_json::Value = response + .json() + .await + .expect("Failed to parse token response"); + + let access_token = token_response["access_token"] + .as_str() + .expect("No access token in response") + .to_string(); + + let refresh_token = token_response["refresh_token"] + .as_str() + .expect("No access token in response") + .to_string(); + + (access_token, refresh_token) +} + +/// Get a new access token with custom credentials +async fn get_custom_access_token( + mint_info: &MintInfo, + username: &str, + password: &str, +) -> Result<(String, String), Error> { + let openid_discovery = mint_info + .nuts + .nut21 + .clone() + .expect("Nutxx defined") + .openid_discovery; + + let oidc_client = OidcClient::new(openid_discovery); + + // Get the token endpoint from the OIDC configuration + let token_url = oidc_client + .get_oidc_config() + .await + .map_err(|_| Error::Custom("Failed to get OIDC config".to_string()))? + .token_endpoint; + + // Create the request parameters + let params = [ + ("grant_type", "password"), + ("client_id", "cashu-client"), + ("username", username), + ("password", password), + ]; + + // Make the token request directly + let client = reqwest::Client::new(); + let response = client + .post(token_url) + .form(¶ms) + .send() + .await + .map_err(|_| Error::Custom("Failed to send token request".to_string()))?; + + if !response.status().is_success() { + return Err(Error::Custom(format!( + "Token request failed with status: {}", + response.status() + ))); + } + + let token_response: serde_json::Value = response + .json() + .await + .map_err(|_| Error::Custom("Failed to parse token response".to_string()))?; + + let access_token = token_response["access_token"] + .as_str() + .ok_or_else(|| Error::Custom("No access token in response".to_string()))? + .to_string(); + + let refresh_token = token_response["refresh_token"] + .as_str() + .ok_or_else(|| Error::Custom("No refresh token in response".to_string()))? + .to_string(); + + Ok((access_token, refresh_token)) +} diff --git a/crates/cdk-integration-tests/tests/fake_wallet.rs b/crates/cdk-integration-tests/tests/fake_wallet.rs index 4b95bad6..91346ea3 100644 --- a/crates/cdk-integration-tests/tests/fake_wallet.rs +++ b/crates/cdk-integration-tests/tests/fake_wallet.rs @@ -8,8 +8,7 @@ use cdk::nuts::{ CurrencyUnit, MeltBolt11Request, MeltQuoteState, MintBolt11Request, PreMintSecrets, Proofs, SecretKey, State, SwapRequest, }; -use cdk::wallet::client::{HttpClient, MintConnector}; -use cdk::wallet::Wallet; +use cdk::wallet::{HttpClient, MintConnector, Wallet}; use cdk_fake_wallet::{create_fake_invoice, FakeInvoiceDescription}; use cdk_integration_tests::{attempt_to_swap_pending, wait_for_mint_to_be_paid}; use cdk_sqlite::wallet::memory; @@ -354,7 +353,7 @@ async fn test_fake_melt_change_in_quote() -> Result<()> { let premint_secrets = PreMintSecrets::random(keyset.id, 100.into(), &SplitTarget::default())?; - let client = HttpClient::new(MINT_URL.parse()?); + let client = HttpClient::new(MINT_URL.parse()?, None); let melt_request = MeltBolt11Request { quote: melt_quote.id.clone(), @@ -450,7 +449,7 @@ async fn test_fake_mint_without_witness() -> Result<()> { wait_for_mint_to_be_paid(&wallet, &mint_quote.id, 60).await?; - let http_client = HttpClient::new(MINT_URL.parse()?); + let http_client = HttpClient::new(MINT_URL.parse()?, None); let active_keyset_id = wallet.get_active_mint_keyset().await?.id; @@ -486,7 +485,7 @@ async fn test_fake_mint_with_wrong_witness() -> Result<()> { wait_for_mint_to_be_paid(&wallet, &mint_quote.id, 60).await?; - let http_client = HttpClient::new(MINT_URL.parse()?); + let http_client = HttpClient::new(MINT_URL.parse()?, None); let active_keyset_id = wallet.get_active_mint_keyset().await?.id; @@ -545,7 +544,7 @@ async fn test_fake_mint_inflated() -> Result<()> { if let Some(secret_key) = quote_info.secret_key { mint_request.sign(secret_key)?; } - let http_client = HttpClient::new(MINT_URL.parse()?); + let http_client = HttpClient::new(MINT_URL.parse()?, None); let response = http_client.post_mint(mint_request.clone()).await; @@ -615,7 +614,7 @@ async fn test_fake_mint_multiple_units() -> Result<()> { if let Some(secret_key) = quote_info.secret_key { mint_request.sign(secret_key)?; } - let http_client = HttpClient::new(MINT_URL.parse()?); + let http_client = HttpClient::new(MINT_URL.parse()?, None); let response = http_client.post_mint(mint_request.clone()).await; @@ -682,7 +681,7 @@ async fn test_fake_mint_multiple_unit_swap() -> Result<()> { outputs: pre_mint.blinded_messages(), }; - let http_client = HttpClient::new(MINT_URL.parse()?); + let http_client = HttpClient::new(MINT_URL.parse()?, None); let response = http_client.post_swap(swap_request.clone()).await; match response { @@ -719,7 +718,7 @@ async fn test_fake_mint_multiple_unit_swap() -> Result<()> { outputs: usd_outputs, }; - let http_client = HttpClient::new(MINT_URL.parse()?); + let http_client = HttpClient::new(MINT_URL.parse()?, None); let response = http_client.post_swap(swap_request.clone()).await; match response { @@ -793,7 +792,7 @@ async fn test_fake_mint_multiple_unit_melt() -> Result<()> { outputs: None, }; - let http_client = HttpClient::new(MINT_URL.parse()?); + let http_client = HttpClient::new(MINT_URL.parse()?, None); let response = http_client.post_melt(melt_request.clone()).await; match response { @@ -837,7 +836,7 @@ async fn test_fake_mint_multiple_unit_melt() -> Result<()> { outputs: Some(usd_outputs), }; - let http_client = HttpClient::new(MINT_URL.parse()?); + let http_client = HttpClient::new(MINT_URL.parse()?, None); let response = http_client.post_melt(melt_request.clone()).await; @@ -896,7 +895,7 @@ async fn test_fake_mint_input_output_mismatch() -> Result<()> { outputs: pre_mint.blinded_messages(), }; - let http_client = HttpClient::new(MINT_URL.parse()?); + let http_client = HttpClient::new(MINT_URL.parse()?, None); let response = http_client.post_swap(swap_request.clone()).await; match response { @@ -936,7 +935,7 @@ async fn test_fake_mint_swap_inflated() -> Result<()> { outputs: pre_mint.blinded_messages(), }; - let http_client = HttpClient::new(MINT_URL.parse()?); + let http_client = HttpClient::new(MINT_URL.parse()?, None); let response = http_client.post_swap(swap_request.clone()).await; match response { @@ -979,7 +978,7 @@ async fn test_fake_mint_swap_spend_after_fail() -> Result<()> { outputs: pre_mint.blinded_messages(), }; - let http_client = HttpClient::new(MINT_URL.parse()?); + let http_client = HttpClient::new(MINT_URL.parse()?, None); let response = http_client.post_swap(swap_request.clone()).await; assert!(response.is_ok()); @@ -991,7 +990,7 @@ async fn test_fake_mint_swap_spend_after_fail() -> Result<()> { outputs: pre_mint.blinded_messages(), }; - let http_client = HttpClient::new(MINT_URL.parse()?); + let http_client = HttpClient::new(MINT_URL.parse()?, None); let response = http_client.post_swap(swap_request.clone()).await; match response { @@ -1009,7 +1008,7 @@ async fn test_fake_mint_swap_spend_after_fail() -> Result<()> { outputs: pre_mint.blinded_messages(), }; - let http_client = HttpClient::new(MINT_URL.parse()?); + let http_client = HttpClient::new(MINT_URL.parse()?, None); let response = http_client.post_swap(swap_request.clone()).await; match response { @@ -1052,7 +1051,7 @@ async fn test_fake_mint_melt_spend_after_fail() -> Result<()> { outputs: pre_mint.blinded_messages(), }; - let http_client = HttpClient::new(MINT_URL.parse()?); + let http_client = HttpClient::new(MINT_URL.parse()?, None); let response = http_client.post_swap(swap_request.clone()).await; assert!(response.is_ok()); @@ -1064,7 +1063,7 @@ async fn test_fake_mint_melt_spend_after_fail() -> Result<()> { outputs: pre_mint.blinded_messages(), }; - let http_client = HttpClient::new(MINT_URL.parse()?); + let http_client = HttpClient::new(MINT_URL.parse()?, None); let response = http_client.post_swap(swap_request.clone()).await; match response { @@ -1085,7 +1084,7 @@ async fn test_fake_mint_melt_spend_after_fail() -> Result<()> { outputs: None, }; - let http_client = HttpClient::new(MINT_URL.parse()?); + let http_client = HttpClient::new(MINT_URL.parse()?, None); let response = http_client.post_melt(melt_request.clone()).await; match response { @@ -1132,7 +1131,7 @@ async fn test_fake_mint_duplicate_proofs_swap() -> Result<()> { outputs: pre_mint.blinded_messages(), }; - let http_client = HttpClient::new(MINT_URL.parse()?); + let http_client = HttpClient::new(MINT_URL.parse()?, None); let response = http_client.post_swap(swap_request.clone()).await; match response { @@ -1156,7 +1155,7 @@ async fn test_fake_mint_duplicate_proofs_swap() -> Result<()> { let swap_request = SwapRequest { inputs, outputs }; - let http_client = HttpClient::new(MINT_URL.parse()?); + let http_client = HttpClient::new(MINT_URL.parse()?, None); let response = http_client.post_swap(swap_request.clone()).await; match response { @@ -1206,7 +1205,7 @@ async fn test_fake_mint_duplicate_proofs_melt() -> Result<()> { outputs: None, }; - let http_client = HttpClient::new(MINT_URL.parse()?); + let http_client = HttpClient::new(MINT_URL.parse()?, None); let response = http_client.post_melt(melt_request.clone()).await; match response { diff --git a/crates/cdk-integration-tests/tests/regtest.rs b/crates/cdk-integration-tests/tests/regtest.rs index 7c840fc2..4c38f152 100644 --- a/crates/cdk-integration-tests/tests/regtest.rs +++ b/crates/cdk-integration-tests/tests/regtest.rs @@ -12,9 +12,7 @@ use cdk::nuts::{ CurrencyUnit, MeltQuoteState, MintBolt11Request, MintQuoteState, NotificationPayload, PreMintSecrets, State, }; -use cdk::wallet::client::{HttpClient, MintConnector}; -use cdk::wallet::Wallet; -use cdk::WalletSubscription; +use cdk::wallet::{HttpClient, MintConnector, Wallet, WalletSubscription}; use cdk_integration_tests::init_regtest::{ get_cln_dir, get_lnd_cert_file_path, get_lnd_dir, get_lnd_macaroon_path, get_mint_port, get_mint_url, get_mint_ws_url, LND_RPC_ADDR, LND_TWO_RPC_ADDR, @@ -442,7 +440,7 @@ async fn test_cached_mint() -> Result<()> { wait_for_mint_to_be_paid(&wallet, "e.id, 60).await?; let active_keyset_id = wallet.get_active_mint_keyset().await?.id; - let http_client = HttpClient::new(get_mint_url("0").as_str().parse()?); + let http_client = HttpClient::new(get_mint_url("0").as_str().parse()?, None); let premint_secrets = PreMintSecrets::random(active_keyset_id, 100.into(), &SplitTarget::default()).unwrap(); diff --git a/crates/cdk-mintd/Cargo.toml b/crates/cdk-mintd/Cargo.toml index d538ff7a..e02734fd 100644 --- a/crates/cdk-mintd/Cargo.toml +++ b/crates/cdk-mintd/Cargo.toml @@ -10,7 +10,7 @@ description = "CDK mint binary" rust-version = "1.75.0" [features] -default = ["management-rpc", "cln", "lnd", "lnbits", "fakewallet", "grpc-processor"] +default = ["management-rpc", "cln", "lnd", "lnbits", "fakewallet", "grpc-processor", "auth"] # Ensure at least one lightning backend is enabled management-rpc = ["cdk-mint-rpc"] cln = ["dep:cdk-cln"] @@ -23,6 +23,7 @@ sqlcipher = ["cdk-sqlite/sqlcipher"] redb = ["dep:cdk-redb"] swagger = ["cdk-axum/swagger", "dep:utoipa", "dep:utoipa-swagger-ui"] redis = ["cdk-axum/redis"] +auth = ["cdk/auth", "cdk-sqlite/auth"] [dependencies] anyhow.workspace = true @@ -33,6 +34,7 @@ cdk = { workspace = true, features = [ ] } cdk-redb = { workspace = true, features = [ "mint", + "auth" ], optional = true } cdk-sqlite = { workspace = true, features = [ "mint", diff --git a/crates/cdk-mintd/example.config.toml b/crates/cdk-mintd/example.config.toml index 8adb6d61..7211cf22 100644 --- a/crates/cdk-mintd/example.config.toml +++ b/crates/cdk-mintd/example.config.toml @@ -98,3 +98,14 @@ reserve_fee_min = 4 # addr = "127.0.0.1" # port = 50051 # tls_dir = "/path/to/tls" + +# [auth] +# openid_discovery = "http://127.0.0.1:8080/realms/cdk-test-realm/.well-known/openid-configuration" +# openid_client_id = "cashu-client" +# mint_max_bat=50 +# enabled_mint=true +# enabled_melt=true +# enabled_swap=true +# enabled_check_mint_quote=true +# enabled_check_melt_quote=true +# enabled_restore=true diff --git a/crates/cdk-mintd/src/config.rs b/crates/cdk-mintd/src/config.rs index 9b8a4729..701ca52c 100644 --- a/crates/cdk-mintd/src/config.rs +++ b/crates/cdk-mintd/src/config.rs @@ -202,6 +202,30 @@ pub struct Database { pub engine: DatabaseEngine, } +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct Auth { + pub openid_discovery: String, + pub openid_client_id: String, + pub mint_max_bat: u64, + #[serde(default = "default_true")] + pub enabled_mint: bool, + #[serde(default = "default_true")] + pub enabled_melt: bool, + #[serde(default = "default_true")] + pub enabled_swap: bool, + #[serde(default = "default_true")] + pub enabled_check_mint_quote: bool, + #[serde(default = "default_true")] + pub enabled_check_melt_quote: bool, + #[serde(default = "default_true")] + pub enabled_restore: bool, + #[serde(default = "default_true")] + pub enabled_check_proof_state: bool, +} + +fn default_true() -> bool { + true +} /// CDK settings, derived from `config.toml` #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct Settings { @@ -220,6 +244,7 @@ pub struct Settings { pub database: Database, #[cfg(feature = "management-rpc")] pub mint_management_rpc: Option, + pub auth: Option, } #[derive(Debug, Clone, Serialize, Deserialize, Default)] diff --git a/crates/cdk-mintd/src/env_vars/auth.rs b/crates/cdk-mintd/src/env_vars/auth.rs new file mode 100644 index 00000000..93a324d2 --- /dev/null +++ b/crates/cdk-mintd/src/env_vars/auth.rs @@ -0,0 +1,78 @@ +//! Auth env + +use std::env; + +use crate::config::Auth; + +pub const ENV_AUTH_OPENID_DISCOVERY: &str = "CDK_MINTD_AUTH_OPENID_DISCOVERY"; +pub const ENV_AUTH_OPENID_CLIENT_ID: &str = "CDK_MINTD_AUTH_OPENID_CLIENT_ID"; +pub const ENV_AUTH_MINT_MAX_BAT: &str = "CDK_MINTD_AUTH_MINT_MAX_BAT"; +pub const ENV_AUTH_ENABLED_MINT: &str = "CDK_MINTD_AUTH_ENABLED_MINT"; +pub const ENV_AUTH_ENABLED_MELT: &str = "CDK_MINTD_AUTH_ENABLED_MELT"; +pub const ENV_AUTH_ENABLED_SWAP: &str = "CDK_MINTD_AUTH_ENABLED_SWAP"; +pub const ENV_AUTH_ENABLED_CHECK_MINT_QUOTE: &str = "CDK_MINTD_AUTH_ENABLED_CHECK_MINT_QUOTE"; +pub const ENV_AUTH_ENABLED_CHECK_MELT_QUOTE: &str = "CDK_MINTD_AUTH_ENABLED_CHECK_MELT_QUOTE"; +pub const ENV_AUTH_ENABLED_RESTORE: &str = "CDK_MINTD_AUTH_ENABLED_RESTORE"; +pub const ENV_AUTH_ENABLED_CHECK_PROOF_STATE: &str = "CDK_MINTD_AUTH_ENABLED_CHECK_PROOF_STATE"; + +impl Auth { + pub fn from_env(mut self) -> Self { + if let Ok(discovery) = env::var(ENV_AUTH_OPENID_DISCOVERY) { + self.openid_discovery = discovery; + } + + if let Ok(client_id) = env::var(ENV_AUTH_OPENID_CLIENT_ID) { + self.openid_client_id = client_id; + } + + if let Ok(max_bat_str) = env::var(ENV_AUTH_MINT_MAX_BAT) { + if let Ok(max_bat) = max_bat_str.parse() { + self.mint_max_bat = max_bat; + } + } + + if let Ok(enabled_mint_str) = env::var(ENV_AUTH_ENABLED_MINT) { + if let Ok(enabled) = enabled_mint_str.parse() { + self.enabled_mint = enabled; + } + } + + if let Ok(enabled_melt_str) = env::var(ENV_AUTH_ENABLED_MELT) { + if let Ok(enabled) = enabled_melt_str.parse() { + self.enabled_melt = enabled; + } + } + + if let Ok(enabled_swap_str) = env::var(ENV_AUTH_ENABLED_SWAP) { + if let Ok(enabled) = enabled_swap_str.parse() { + self.enabled_swap = enabled; + } + } + + if let Ok(enabled_check_mint_str) = env::var(ENV_AUTH_ENABLED_CHECK_MINT_QUOTE) { + if let Ok(enabled) = enabled_check_mint_str.parse() { + self.enabled_check_mint_quote = enabled; + } + } + + if let Ok(enabled_check_melt_str) = env::var(ENV_AUTH_ENABLED_CHECK_MELT_QUOTE) { + if let Ok(enabled) = enabled_check_melt_str.parse() { + self.enabled_check_melt_quote = enabled; + } + } + + if let Ok(enabled_restore_str) = env::var(ENV_AUTH_ENABLED_RESTORE) { + if let Ok(enabled) = enabled_restore_str.parse() { + self.enabled_restore = enabled; + } + } + + if let Ok(enabled_check_proof_str) = env::var(ENV_AUTH_ENABLED_CHECK_PROOF_STATE) { + if let Ok(enabled) = enabled_check_proof_str.parse() { + self.enabled_check_proof_state = enabled; + } + } + + self + } +} diff --git a/crates/cdk-mintd/src/env_vars/mod.rs b/crates/cdk-mintd/src/env_vars/mod.rs index 1be11cee..662432bd 100644 --- a/crates/cdk-mintd/src/env_vars/mod.rs +++ b/crates/cdk-mintd/src/env_vars/mod.rs @@ -8,6 +8,8 @@ mod info; mod ln; mod mint_info; +#[cfg(feature = "auth")] +mod auth; #[cfg(feature = "cln")] mod cln; #[cfg(feature = "fakewallet")] @@ -25,6 +27,8 @@ use std::env; use std::str::FromStr; use anyhow::{anyhow, bail, Result}; +#[cfg(feature = "auth")] +pub use auth::*; #[cfg(feature = "cln")] pub use cln::*; pub use common::*; @@ -54,6 +58,25 @@ impl Settings { self.mint_info = self.mint_info.clone().from_env(); self.ln = self.ln.clone().from_env(); + #[cfg(feature = "auth")] + { + // Check env vars for auth config even if None + let auth = self.auth.clone().unwrap_or_default().from_env(); + + // Only set auth if env vars are present and have non-default values + if auth.openid_discovery != String::default() + || auth.openid_client_id != String::default() + || auth.mint_max_bat != 0 + || auth.enabled_mint + || auth.enabled_melt + || auth.enabled_swap + { + self.auth = Some(auth); + } else { + self.auth = None; + } + } + #[cfg(feature = "management-rpc")] { self.mint_management_rpc = Some( diff --git a/crates/cdk-mintd/src/main.rs b/crates/cdk-mintd/src/main.rs index 6863d10d..20ce2758 100644 --- a/crates/cdk-mintd/src/main.rs +++ b/crates/cdk-mintd/src/main.rs @@ -1,8 +1,8 @@ -//! CDK Mint Server - +//! CDK MINTD #![warn(missing_docs)] #![warn(rustdoc::bare_urls)] +use std::collections::HashMap; use std::env; use std::net::SocketAddr; use std::path::PathBuf; @@ -12,7 +12,7 @@ use std::sync::Arc; use anyhow::{anyhow, bail, Result}; use axum::Router; use bip39::Mnemonic; -use cdk::cdk_database::{self, MintDatabase}; +use cdk::cdk_database::{self, MintAuthDatabase, MintDatabase}; use cdk::mint::{MintBuilder, MintMeltLimits}; // Feature-gated imports #[cfg(any( @@ -31,7 +31,9 @@ use cdk::nuts::nut19::{CachedEndpoint, Method as NUT19Method, Path as NUT19Path} feature = "fakewallet" ))] use cdk::nuts::CurrencyUnit; -use cdk::nuts::{ContactInfo, MintVersion, PaymentMethod}; +use cdk::nuts::{ + AuthRequired, ContactInfo, MintVersion, PaymentMethod, ProtectedEndpoint, RoutePath, +}; use cdk::types::QuoteTTL; use cdk_axum::cache::HttpCache; #[cfg(feature = "management-rpc")] @@ -41,7 +43,10 @@ use cdk_mintd::config::{self, DatabaseEngine, LnBackend}; use cdk_mintd::env_vars::ENV_WORK_DIR; use cdk_mintd::setup::LnBackendSetup; #[cfg(feature = "redb")] +use cdk_redb::mint::MintRedbAuthDatabase; +#[cfg(feature = "redb")] use cdk_redb::MintRedbDatabase; +use cdk_sqlite::mint::MintSqliteAuthDatabase; use cdk_sqlite::MintSqliteDatabase; use clap::Parser; use tokio::sync::Notify; @@ -361,6 +366,157 @@ async fn main() -> anyhow::Result<()> { mint_builder = mint_builder.add_cache(Some(cache.ttl.as_secs()), cached_endpoints); + // Add auth to mint + if let Some(auth_settings) = settings.auth { + tracing::info!("Auth settings are defined. {:?}", auth_settings); + let auth_localstore: Arc + Send + Sync> = + match settings.database.engine { + DatabaseEngine::Sqlite => { + let sql_db_path = work_dir.join("cdk-mintd-auth.sqlite"); + let sqlite_db = MintSqliteAuthDatabase::new(&sql_db_path).await?; + + sqlite_db.migrate().await; + + Arc::new(sqlite_db) + } + #[cfg(feature = "redb")] + DatabaseEngine::Redb => { + let redb_path = work_dir.join("cdk-mintd-auth.redb"); + Arc::new(MintRedbAuthDatabase::new(&redb_path)?) + } + }; + + mint_builder = mint_builder.with_auth_localstore(auth_localstore.clone()); + + let mint_blind_auth_endpoint = + ProtectedEndpoint::new(cdk::nuts::Method::Post, RoutePath::MintBlindAuth); + + mint_builder = mint_builder.set_clear_auth_settings( + auth_settings.openid_discovery, + auth_settings.openid_client_id, + ); + + let mut protected_endpoints = HashMap::new(); + + protected_endpoints.insert(mint_blind_auth_endpoint, AuthRequired::Clear); + + let mut blind_auth_endpoints = vec![]; + let mut unprotected_endpoints = vec![]; + + { + let mint_quote_protected_endpoint = ProtectedEndpoint::new( + cdk::nuts::Method::Post, + cdk::nuts::RoutePath::MintQuoteBolt11, + ); + let mint_protected_endpoint = + ProtectedEndpoint::new(cdk::nuts::Method::Post, cdk::nuts::RoutePath::MintBolt11); + if auth_settings.enabled_mint { + protected_endpoints.insert(mint_quote_protected_endpoint, AuthRequired::Blind); + + protected_endpoints.insert(mint_protected_endpoint, AuthRequired::Blind); + + blind_auth_endpoints.push(mint_quote_protected_endpoint); + blind_auth_endpoints.push(mint_protected_endpoint); + } else { + unprotected_endpoints.push(mint_protected_endpoint); + unprotected_endpoints.push(mint_quote_protected_endpoint); + } + } + + { + let melt_quote_protected_endpoint = ProtectedEndpoint::new( + cdk::nuts::Method::Post, + cdk::nuts::RoutePath::MeltQuoteBolt11, + ); + let melt_protected_endpoint = + ProtectedEndpoint::new(cdk::nuts::Method::Post, cdk::nuts::RoutePath::MeltBolt11); + + if auth_settings.enabled_melt { + protected_endpoints.insert(melt_quote_protected_endpoint, AuthRequired::Blind); + protected_endpoints.insert(melt_protected_endpoint, AuthRequired::Blind); + + blind_auth_endpoints.push(melt_quote_protected_endpoint); + blind_auth_endpoints.push(melt_protected_endpoint); + } else { + unprotected_endpoints.push(melt_quote_protected_endpoint); + unprotected_endpoints.push(melt_protected_endpoint); + } + } + + { + let swap_protected_endpoint = + ProtectedEndpoint::new(cdk::nuts::Method::Post, cdk::nuts::RoutePath::Swap); + + if auth_settings.enabled_swap { + protected_endpoints.insert(swap_protected_endpoint, AuthRequired::Blind); + blind_auth_endpoints.push(swap_protected_endpoint); + } else { + unprotected_endpoints.push(swap_protected_endpoint); + } + } + + { + let check_mint_protected_endpoint = ProtectedEndpoint::new( + cdk::nuts::Method::Get, + cdk::nuts::RoutePath::MintQuoteBolt11, + ); + + if auth_settings.enabled_check_mint_quote { + protected_endpoints.insert(check_mint_protected_endpoint, AuthRequired::Blind); + blind_auth_endpoints.push(check_mint_protected_endpoint); + } else { + unprotected_endpoints.push(check_mint_protected_endpoint); + } + } + + { + let check_melt_protected_endpoint = ProtectedEndpoint::new( + cdk::nuts::Method::Get, + cdk::nuts::RoutePath::MeltQuoteBolt11, + ); + + if auth_settings.enabled_check_melt_quote { + protected_endpoints.insert(check_melt_protected_endpoint, AuthRequired::Blind); + blind_auth_endpoints.push(check_melt_protected_endpoint); + } else { + unprotected_endpoints.push(check_melt_protected_endpoint); + } + } + + { + let restore_protected_endpoint = + ProtectedEndpoint::new(cdk::nuts::Method::Post, cdk::nuts::RoutePath::Restore); + + if auth_settings.enabled_restore { + protected_endpoints.insert(restore_protected_endpoint, AuthRequired::Blind); + blind_auth_endpoints.push(restore_protected_endpoint); + } else { + unprotected_endpoints.push(restore_protected_endpoint); + } + } + + { + let state_protected_endpoint = + ProtectedEndpoint::new(cdk::nuts::Method::Post, cdk::nuts::RoutePath::Checkstate); + + if auth_settings.enabled_check_proof_state { + protected_endpoints.insert(state_protected_endpoint, AuthRequired::Blind); + blind_auth_endpoints.push(state_protected_endpoint); + } else { + unprotected_endpoints.push(state_protected_endpoint); + } + } + + mint_builder = mint_builder.set_blind_auth_settings(auth_settings.mint_max_bat); + + auth_localstore + .remove_protected_endpoints(unprotected_endpoints) + .await?; + auth_localstore + .add_protected_endpoints(protected_endpoints) + .await?; + } + let mint = mint_builder.build().await?; tracing::debug!("Mint built from builder."); diff --git a/crates/cdk-redb/Cargo.toml b/crates/cdk-redb/Cargo.toml index b839b6c5..f33a0040 100644 --- a/crates/cdk-redb/Cargo.toml +++ b/crates/cdk-redb/Cargo.toml @@ -11,9 +11,10 @@ rust-version = "1.81.0" # MSRV # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [features] -default = ["mint", "wallet"] +default = ["mint", "wallet", "auth"] mint = ["cdk-common/mint"] wallet = ["cdk-common/wallet"] +auth = ["cdk-common/auth"] [dependencies] async-trait.workspace = true diff --git a/crates/cdk-redb/src/mint/auth/mod.rs b/crates/cdk-redb/src/mint/auth/mod.rs new file mode 100644 index 00000000..fcbef222 --- /dev/null +++ b/crates/cdk-redb/src/mint/auth/mod.rs @@ -0,0 +1,391 @@ +use std::cmp::Ordering; +use std::collections::HashMap; +use std::path::Path; +use std::str::FromStr; +use std::sync::Arc; + +use async_trait::async_trait; +use cdk_common::database::{self, MintAuthDatabase}; +use cdk_common::dhke::hash_to_curve; +use cdk_common::mint::MintKeySetInfo; +use cdk_common::nuts::{AuthProof, BlindSignature, Id, PublicKey, State}; +use cdk_common::{AuthRequired, ProtectedEndpoint}; +use redb::{Database, ReadableTable, TableDefinition}; + +use crate::error::Error; + +const CONFIG_TABLE: TableDefinition<&str, &str> = TableDefinition::new("config"); +const ACTIVE_KEYSET_TABLE: TableDefinition<&str, &str> = TableDefinition::new("active_keyset"); +const KEYSETS_TABLE: TableDefinition<&str, &str> = TableDefinition::new("keysets"); +const PROOFS_TABLE: TableDefinition<[u8; 33], &str> = TableDefinition::new("proofs"); +const PROOFS_STATE_TABLE: TableDefinition<[u8; 33], &str> = TableDefinition::new("proofs_state"); +// Key is hex blinded_message B_ value is blinded_signature +const BLINDED_SIGNATURES: TableDefinition<[u8; 33], &str> = + TableDefinition::new("blinded_signatures"); +const ENDPOINTS_TABLE: TableDefinition<&str, &str> = TableDefinition::new("endpoints"); + +/// Mint Redbdatabase +#[derive(Debug, Clone)] +pub struct MintRedbAuthDatabase { + db: Arc, +} + +const DATABASE_VERSION: u32 = 0; + +impl MintRedbAuthDatabase { + /// Create new [`MintRedbDatabase`] + pub fn new(path: &Path) -> Result { + { + // Check database version + + let db = Arc::new(Database::create(path)?); + + // Check database version + let read_txn = db.begin_read()?; + let table = read_txn.open_table(CONFIG_TABLE); + + let db_version = match table { + Ok(table) => table.get("db_version")?.map(|v| v.value().to_owned()), + Err(_) => None, + }; + match db_version { + Some(db_version) => { + let current_file_version = u32::from_str(&db_version)?; + match current_file_version.cmp(&DATABASE_VERSION) { + Ordering::Less => { + tracing::info!( + "Database needs to be upgraded at {} current is {}", + current_file_version, + DATABASE_VERSION + ); + } + Ordering::Equal => { + tracing::info!("Database is at current version {}", DATABASE_VERSION); + } + Ordering::Greater => { + tracing::warn!( + "Database upgrade did not complete at {} current is {}", + current_file_version, + DATABASE_VERSION + ); + return Err(Error::UnknownDatabaseVersion); + } + } + } + None => { + let write_txn = db.begin_write()?; + { + let mut table = write_txn.open_table(CONFIG_TABLE)?; + // Open all tables to init a new db + let _ = write_txn.open_table(ACTIVE_KEYSET_TABLE)?; + let _ = write_txn.open_table(KEYSETS_TABLE)?; + let _ = write_txn.open_table(PROOFS_TABLE)?; + let _ = write_txn.open_table(PROOFS_STATE_TABLE)?; + let _ = write_txn.open_table(BLINDED_SIGNATURES)?; + + table.insert("db_version", DATABASE_VERSION.to_string().as_str())?; + } + + write_txn.commit()?; + } + } + drop(db); + } + + let db = Database::create(path)?; + Ok(Self { db: Arc::new(db) }) + } +} + +#[async_trait] +impl MintAuthDatabase for MintRedbAuthDatabase { + type Err = database::Error; + + async fn set_active_keyset(&self, id: Id) -> Result<(), Self::Err> { + let write_txn = self.db.begin_write().map_err(Error::from)?; + + { + let mut table = write_txn + .open_table(ACTIVE_KEYSET_TABLE) + .map_err(Error::from)?; + table + .insert("active_keyset_id", id.to_string().as_str()) + .map_err(Error::from)?; + } + write_txn.commit().map_err(Error::from)?; + + Ok(()) + } + + async fn get_active_keyset_id(&self) -> Result, Self::Err> { + let read_txn = self.db.begin_read().map_err(Error::from)?; + let table = read_txn + .open_table(ACTIVE_KEYSET_TABLE) + .map_err(Error::from)?; + + if let Some(id) = table.get("active_keyset_id").map_err(Error::from)? { + return Ok(Some(Id::from_str(id.value()).map_err(Error::from)?)); + } + + Ok(None) + } + + async fn add_keyset_info(&self, keyset: MintKeySetInfo) -> Result<(), Self::Err> { + let write_txn = self.db.begin_write().map_err(Error::from)?; + + { + let mut table = write_txn.open_table(KEYSETS_TABLE).map_err(Error::from)?; + table + .insert( + keyset.id.to_string().as_str(), + serde_json::to_string(&keyset) + .map_err(Error::from)? + .as_str(), + ) + .map_err(Error::from)?; + } + write_txn.commit().map_err(Error::from)?; + + Ok(()) + } + + async fn get_keyset_info(&self, keyset_id: &Id) -> Result, Self::Err> { + let read_txn = self.db.begin_read().map_err(Error::from)?; + let table = read_txn.open_table(KEYSETS_TABLE).map_err(Error::from)?; + + match table + .get(keyset_id.to_string().as_str()) + .map_err(Error::from)? + { + Some(keyset) => Ok(serde_json::from_str(keyset.value()).map_err(Error::from)?), + None => Ok(None), + } + } + + async fn get_keyset_infos(&self) -> Result, Self::Err> { + let read_txn = self.db.begin_read().map_err(Error::from)?; + let table = read_txn.open_table(KEYSETS_TABLE).map_err(Error::from)?; + + let mut keysets = Vec::new(); + + for (_id, keyset) in (table.iter().map_err(Error::from)?).flatten() { + let keyset = serde_json::from_str(keyset.value()).map_err(Error::from)?; + + keysets.push(keyset) + } + + Ok(keysets) + } + + async fn add_proof(&self, proof: AuthProof) -> Result<(), Self::Err> { + let write_txn = self.db.begin_write().map_err(Error::from)?; + + { + let mut table = write_txn.open_table(PROOFS_TABLE).map_err(Error::from)?; + let y: PublicKey = hash_to_curve(&proof.secret.to_bytes()).map_err(Error::from)?; + let y = y.to_bytes(); + if table.get(y).map_err(Error::from)?.is_none() { + table + .insert( + y, + serde_json::to_string(&proof).map_err(Error::from)?.as_str(), + ) + .map_err(Error::from)?; + } + } + write_txn.commit().map_err(Error::from)?; + + Ok(()) + } + + async fn update_proof_state( + &self, + y: &PublicKey, + proof_state: State, + ) -> Result, Self::Err> { + let write_txn = self.db.begin_write().map_err(Error::from)?; + + let state_str = serde_json::to_string(&proof_state).map_err(Error::from)?; + + let current_state; + + { + let mut table = write_txn + .open_table(PROOFS_STATE_TABLE) + .map_err(Error::from)?; + + { + match table.get(y.to_bytes()).map_err(Error::from)? { + Some(state) => { + current_state = + Some(serde_json::from_str(state.value()).map_err(Error::from)?) + } + None => current_state = None, + } + } + + if current_state != Some(State::Spent) { + table + .insert(y.to_bytes(), state_str.as_str()) + .map_err(Error::from)?; + } + } + + write_txn.commit().map_err(Error::from)?; + + Ok(current_state) + } + + async fn get_proofs_states(&self, ys: &[PublicKey]) -> Result>, Self::Err> { + let read_txn = self.db.begin_read().map_err(Error::from)?; + let table = read_txn + .open_table(PROOFS_STATE_TABLE) + .map_err(Error::from)?; + + let mut states = Vec::with_capacity(ys.len()); + + for y in ys { + match table.get(y.to_bytes()).map_err(Error::from)? { + Some(state) => states.push(Some( + serde_json::from_str(state.value()).map_err(Error::from)?, + )), + None => states.push(None), + } + } + + Ok(states) + } + + async fn add_blind_signatures( + &self, + blinded_messages: &[PublicKey], + blind_signatures: &[BlindSignature], + ) -> Result<(), Self::Err> { + let write_txn = self.db.begin_write().map_err(Error::from)?; + + { + let mut table = write_txn + .open_table(BLINDED_SIGNATURES) + .map_err(Error::from)?; + + for (blinded_message, blind_signature) in blinded_messages.iter().zip(blind_signatures) + { + let blind_sig = serde_json::to_string(&blind_signature).map_err(Error::from)?; + table + .insert(blinded_message.to_bytes(), blind_sig.as_str()) + .map_err(Error::from)?; + } + } + + write_txn.commit().map_err(Error::from)?; + + Ok(()) + } + + async fn get_blind_signatures( + &self, + blinded_messages: &[PublicKey], + ) -> Result>, Self::Err> { + let read_txn = self.db.begin_read().map_err(Error::from)?; + let table = read_txn + .open_table(BLINDED_SIGNATURES) + .map_err(Error::from)?; + + let mut signatures = Vec::with_capacity(blinded_messages.len()); + + for blinded_message in blinded_messages { + match table.get(blinded_message.to_bytes()).map_err(Error::from)? { + Some(blind_signature) => signatures.push(Some( + serde_json::from_str(blind_signature.value()).map_err(Error::from)?, + )), + None => signatures.push(None), + } + } + + Ok(signatures) + } + + async fn add_protected_endpoints( + &self, + protected_endpoints: HashMap, + ) -> Result<(), Self::Err> { + let write_txn = self.db.begin_write().map_err(Error::from)?; + + { + let mut table = write_txn.open_table(ENDPOINTS_TABLE).map_err(Error::from)?; + for (endpoint, auth) in protected_endpoints.iter() { + table + .insert( + serde_json::to_string(endpoint) + .map_err(Error::from)? + .as_str(), + serde_json::to_string(&auth).map_err(Error::from)?.as_str(), + ) + .map_err(Error::from)?; + } + } + write_txn.commit().map_err(Error::from)?; + + Ok(()) + } + + async fn remove_protected_endpoints( + &self, + protected_endpoints: Vec, + ) -> Result<(), Self::Err> { + let write_txn = self.db.begin_write().map_err(Error::from)?; + + { + let mut table = write_txn.open_table(ENDPOINTS_TABLE).map_err(Error::from)?; + for endpoint in protected_endpoints.iter() { + table + .remove( + serde_json::to_string(endpoint) + .map_err(Error::from)? + .as_str(), + ) + .map_err(Error::from)?; + } + } + write_txn.commit().map_err(Error::from)?; + + Ok(()) + } + async fn get_auth_for_endpoint( + &self, + protected_endpoint: ProtectedEndpoint, + ) -> Result, Self::Err> { + let read_txn = self.db.begin_read().map_err(Error::from)?; + let table = read_txn.open_table(ENDPOINTS_TABLE).map_err(Error::from)?; + + match table + .get( + serde_json::to_string(&protected_endpoint) + .map_err(Error::from)? + .as_str(), + ) + .map_err(Error::from)? + { + Some(auth) => Ok(serde_json::from_str(auth.value()).map_err(Error::from)?), + None => Ok(None), + } + } + async fn get_auth_for_endpoints( + &self, + ) -> Result>, Self::Err> { + let read_txn = self.db.begin_read().map_err(Error::from)?; + let table = read_txn.open_table(ENDPOINTS_TABLE).map_err(Error::from)?; + + let mut protected = HashMap::new(); + + for (endpoint, auth) in (table.iter().map_err(Error::from)?).flatten() { + let endpoint: ProtectedEndpoint = + serde_json::from_str(endpoint.value()).map_err(Error::from)?; + let auth: AuthRequired = serde_json::from_str(auth.value()).map_err(Error::from)?; + + protected.insert(endpoint, Some(auth)); + } + + Ok(protected) + } +} diff --git a/crates/cdk-redb/src/mint/mod.rs b/crates/cdk-redb/src/mint/mod.rs index af3bd5cd..539281c3 100644 --- a/crates/cdk-redb/src/mint/mod.rs +++ b/crates/cdk-redb/src/mint/mod.rs @@ -24,8 +24,13 @@ use super::error::Error; use crate::migrations::migrate_00_to_01; use crate::mint::migrations::{migrate_02_to_03, migrate_03_to_04}; +#[cfg(feature = "auth")] +mod auth; mod migrations; +#[cfg(feature = "auth")] +pub use auth::MintRedbAuthDatabase; + const ACTIVE_KEYSETS_TABLE: TableDefinition<&str, &str> = TableDefinition::new("active_keysets"); const KEYSETS_TABLE: TableDefinition<&str, &str> = TableDefinition::new("keysets"); const MINT_QUOTES_TABLE: TableDefinition<[u8; 16], &str> = TableDefinition::new("mint_quotes"); @@ -133,8 +138,8 @@ impl MintRedbDatabase { None => { let write_txn = db.begin_write()?; { - let mut table = write_txn.open_table(CONFIG_TABLE)?; // Open all tables to init a new db + let mut table = write_txn.open_table(CONFIG_TABLE)?; let _ = write_txn.open_table(ACTIVE_KEYSETS_TABLE)?; let _ = write_txn.open_table(KEYSETS_TABLE)?; let _ = write_txn.open_table(MINT_QUOTES_TABLE)?; @@ -965,7 +970,7 @@ mod tests { let proofs = vec![ Proof { amount: Amount::from(100), - keyset_id: keyset_id.clone(), + keyset_id, secret: Secret::generate(), c: SecretKey::generate().public_key(), witness: None, @@ -973,7 +978,7 @@ mod tests { }, Proof { amount: Amount::from(200), - keyset_id: keyset_id.clone(), + keyset_id, secret: Secret::generate(), c: SecretKey::generate().public_key(), witness: None, @@ -1026,7 +1031,7 @@ mod tests { let proofs = vec![ Proof { amount: Amount::from(100), - keyset_id: keyset_id.clone(), + keyset_id, secret: Secret::generate(), c: SecretKey::generate().public_key(), witness: None, @@ -1034,7 +1039,7 @@ mod tests { }, Proof { amount: Amount::from(200), - keyset_id: keyset_id.clone(), + keyset_id, secret: Secret::generate(), c: SecretKey::generate().public_key(), witness: None, diff --git a/crates/cdk-sqlite/Cargo.toml b/crates/cdk-sqlite/Cargo.toml index 205044a3..856fb443 100644 --- a/crates/cdk-sqlite/Cargo.toml +++ b/crates/cdk-sqlite/Cargo.toml @@ -11,9 +11,10 @@ rust-version = "1.75.0" # MSRV # See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html [features] -default = ["mint", "wallet"] +default = ["mint", "wallet", "auth"] mint = ["cdk-common/mint"] wallet = ["cdk-common/wallet"] +auth = ["cdk-common/auth"] sqlcipher = ["libsqlite3-sys"] [dependencies] diff --git a/crates/cdk-sqlite/src/mint/auth/migrations/20250109143347_init.sql b/crates/cdk-sqlite/src/mint/auth/migrations/20250109143347_init.sql new file mode 100644 index 00000000..22eaf315 --- /dev/null +++ b/crates/cdk-sqlite/src/mint/auth/migrations/20250109143347_init.sql @@ -0,0 +1,44 @@ +CREATE TABLE IF NOT EXISTS proof ( +y BLOB PRIMARY KEY, +keyset_id TEXT NOT NULL, +secret TEXT NOT NULL, +c BLOB NOT NULL, +state TEXT NOT NULL +); + +CREATE INDEX IF NOT EXISTS state_index ON proof(state); +CREATE INDEX IF NOT EXISTS secret_index ON proof(secret); + + +-- Keysets Table + +CREATE TABLE IF NOT EXISTS keyset ( + id TEXT PRIMARY KEY, + unit TEXT NOT NULL, + active BOOL NOT NULL, + valid_from INTEGER NOT NULL, + valid_to INTEGER, + derivation_path TEXT NOT NULL, + max_order INTEGER NOT NULL, + derivation_path_index INTEGER NOT NULL +); + +CREATE INDEX IF NOT EXISTS unit_index ON keyset(unit); +CREATE INDEX IF NOT EXISTS active_index ON keyset(active); + + +CREATE TABLE IF NOT EXISTS blind_signature ( + y BLOB PRIMARY KEY, + amount INTEGER NOT NULL, + keyset_id TEXT NOT NULL, + c BLOB NOT NULL +); + +CREATE INDEX IF NOT EXISTS keyset_id_index ON blind_signature(keyset_id); + + +CREATE TABLE IF NOT EXISTS protected_endpoints ( + endpoint TEXT PRIMARY KEY, + auth TEXT NOT NULL +); + diff --git a/crates/cdk-sqlite/src/mint/auth/mod.rs b/crates/cdk-sqlite/src/mint/auth/mod.rs new file mode 100644 index 00000000..4b7409ed --- /dev/null +++ b/crates/cdk-sqlite/src/mint/auth/mod.rs @@ -0,0 +1,539 @@ +//! SQLite Mint Auth + +use std::collections::HashMap; +use std::path::Path; +use std::str::FromStr; +use std::time::Duration; + +use async_trait::async_trait; +use cdk_common::database::{self, MintAuthDatabase}; +use cdk_common::mint::MintKeySetInfo; +use cdk_common::nuts::{AuthProof, BlindSignature, Id, PublicKey, State}; +use cdk_common::{AuthRequired, ProtectedEndpoint}; +use sqlx::sqlite::{SqliteConnectOptions, SqlitePool, SqlitePoolOptions}; +use sqlx::Row; +use tracing::instrument; + +use super::{sqlite_row_to_blind_signature, sqlite_row_to_keyset_info}; +use crate::mint::Error; + +/// Mint SQLite Database +#[derive(Debug, Clone)] +pub struct MintSqliteAuthDatabase { + pool: SqlitePool, +} + +impl MintSqliteAuthDatabase { + /// Create new [`MintSqliteDatabase`] + pub async fn new(path: &Path) -> Result { + let path = path.to_str().ok_or(Error::InvalidDbPath)?; + let db_options = SqliteConnectOptions::from_str(path)? + .busy_timeout(Duration::from_secs(5)) + .read_only(false) + .create_if_missing(true) + .auto_vacuum(sqlx::sqlite::SqliteAutoVacuum::Full); + + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect_with(db_options) + .await?; + + Ok(Self { pool }) + } + + /// Migrate [`MintSqliteDatabase`] + pub async fn migrate(&self) { + sqlx::migrate!("./src/mint/auth/migrations") + .run(&self.pool) + .await + .expect("Could not run migrations"); + } +} + +#[async_trait] +impl MintAuthDatabase for MintSqliteAuthDatabase { + type Err = database::Error; + + #[instrument(skip(self))] + async fn set_active_keyset(&self, id: Id) -> Result<(), Self::Err> { + tracing::info!("Setting auth keyset {id} active"); + let mut transaction = self.pool.begin().await.map_err(Error::from)?; + let update_res = sqlx::query( + r#" + UPDATE keyset + SET active = CASE + WHEN id = ? THEN TRUE + ELSE FALSE + END; + "#, + ) + .bind(id.to_string()) + .execute(&mut *transaction) + .await; + + match update_res { + Ok(_) => { + transaction.commit().await.map_err(Error::from)?; + Ok(()) + } + Err(err) => { + tracing::error!("SQLite Could not update keyset"); + if let Err(err) = transaction.rollback().await { + tracing::error!("Could not rollback sql transaction: {}", err); + } + Err(Error::from(err).into()) + } + } + } + + async fn get_active_keyset_id(&self) -> Result, Self::Err> { + let mut transaction = self.pool.begin().await.map_err(Error::from)?; + + let rec = sqlx::query( + r#" +SELECT id +FROM keyset +WHERE active = 1; + "#, + ) + .fetch_one(&mut *transaction) + .await; + + let rec = match rec { + Ok(rec) => { + transaction.commit().await.map_err(Error::from)?; + rec + } + Err(err) => match err { + sqlx::Error::RowNotFound => { + transaction.commit().await.map_err(Error::from)?; + return Ok(None); + } + _ => { + return { + if let Err(err) = transaction.rollback().await { + tracing::error!("Could not rollback sql transaction: {}", err); + } + Err(Error::SQLX(err).into()) + } + } + }, + }; + + Ok(Some( + Id::from_str(rec.try_get("id").map_err(Error::from)?).map_err(Error::from)?, + )) + } + + async fn add_keyset_info(&self, keyset: MintKeySetInfo) -> Result<(), Self::Err> { + let mut transaction = self.pool.begin().await.map_err(Error::from)?; + let res = sqlx::query( + r#" +INSERT OR REPLACE INTO keyset +(id, unit, active, valid_from, valid_to, derivation_path, max_order, derivation_path_index) +VALUES (?, ?, ?, ?, ?, ?, ?, ?); + "#, + ) + .bind(keyset.id.to_string()) + .bind(keyset.unit.to_string()) + .bind(keyset.active) + .bind(keyset.valid_from as i64) + .bind(keyset.valid_to.map(|v| v as i64)) + .bind(keyset.derivation_path.to_string()) + .bind(keyset.max_order) + .bind(keyset.derivation_path_index) + .execute(&mut *transaction) + .await; + + match res { + Ok(_) => { + transaction.commit().await.map_err(Error::from)?; + Ok(()) + } + Err(err) => { + tracing::error!("SQLite could not add keyset info"); + if let Err(err) = transaction.rollback().await { + tracing::error!("Could not rollback sql transaction: {}", err); + } + + Err(Error::from(err).into()) + } + } + } + + async fn get_keyset_info(&self, id: &Id) -> Result, Self::Err> { + let mut transaction = self.pool.begin().await.map_err(Error::from)?; + let rec = sqlx::query( + r#" +SELECT * +FROM keyset +WHERE id=?; + "#, + ) + .bind(id.to_string()) + .fetch_one(&mut *transaction) + .await; + + match rec { + Ok(rec) => { + transaction.commit().await.map_err(Error::from)?; + Ok(Some(sqlite_row_to_keyset_info(rec)?)) + } + Err(err) => match err { + sqlx::Error::RowNotFound => { + transaction.commit().await.map_err(Error::from)?; + return Ok(None); + } + _ => { + tracing::error!("SQLite could not get keyset info"); + if let Err(err) = transaction.rollback().await { + tracing::error!("Could not rollback sql transaction: {}", err); + } + return Err(Error::SQLX(err).into()); + } + }, + } + } + + async fn get_keyset_infos(&self) -> Result, Self::Err> { + let mut transaction = self.pool.begin().await.map_err(Error::from)?; + let recs = sqlx::query( + r#" +SELECT * +FROM keyset; + "#, + ) + .fetch_all(&mut *transaction) + .await + .map_err(Error::from); + + match recs { + Ok(recs) => { + transaction.commit().await.map_err(Error::from)?; + Ok(recs + .into_iter() + .map(sqlite_row_to_keyset_info) + .collect::>()?) + } + Err(err) => { + tracing::error!("SQLite could not get keyset info"); + if let Err(err) = transaction.rollback().await { + tracing::error!("Could not rollback sql transaction: {}", err); + } + Err(err.into()) + } + } + } + + async fn add_proof(&self, proof: AuthProof) -> Result<(), Self::Err> { + let mut transaction = self.pool.begin().await.map_err(Error::from)?; + if let Err(err) = sqlx::query( + r#" +INSERT INTO proof +(y, keyset_id, secret, c, state) +VALUES (?, ?, ?, ?, ?); + "#, + ) + .bind(proof.y()?.to_bytes().to_vec()) + .bind(proof.keyset_id.to_string()) + .bind(proof.secret.to_string()) + .bind(proof.c.to_bytes().to_vec()) + .bind("UNSPENT") + .execute(&mut *transaction) + .await + .map_err(Error::from) + { + tracing::debug!("Attempting to add known proof. Skipping.... {:?}", err); + } + transaction.commit().await.map_err(Error::from)?; + + Ok(()) + } + + async fn get_proofs_states(&self, ys: &[PublicKey]) -> Result>, Self::Err> { + let mut transaction = self.pool.begin().await.map_err(Error::from)?; + + let sql = format!( + "SELECT y, state FROM proof WHERE y IN ({})", + "?,".repeat(ys.len()).trim_end_matches(',') + ); + + let mut current_states = ys + .iter() + .fold(sqlx::query(&sql), |query, y| { + query.bind(y.to_bytes().to_vec()) + }) + .fetch_all(&mut *transaction) + .await + .map_err(|err| { + tracing::error!("SQLite could not get state of proof: {err:?}"); + Error::SQLX(err) + })? + .into_iter() + .map(|row| { + PublicKey::from_slice(row.get("y")) + .map_err(Error::from) + .and_then(|y| { + let state: String = row.get("state"); + State::from_str(&state) + .map_err(Error::from) + .map(|state| (y, state)) + }) + }) + .collect::, _>>()?; + + Ok(ys.iter().map(|y| current_states.remove(y)).collect()) + } + + async fn update_proof_state( + &self, + y: &PublicKey, + proofs_state: State, + ) -> Result, Self::Err> { + let mut transaction = self.pool.begin().await.map_err(Error::from)?; + + // Get current state for single y + let current_state = sqlx::query("SELECT state FROM proof WHERE y = ?") + .bind(y.to_bytes().to_vec()) + .fetch_optional(&mut *transaction) + .await + .map_err(|err| { + tracing::error!("SQLite could not get state of proof: {err:?}"); + Error::SQLX(err) + })? + .map(|row| { + let state: String = row.get("state"); + State::from_str(&state).map_err(Error::from) + }) + .transpose()?; + + // Update state for single y + sqlx::query("UPDATE proof SET state = ? WHERE state != ? AND y = ?") + .bind(proofs_state.to_string()) + .bind(State::Spent.to_string()) + .bind(y.to_bytes().to_vec()) + .execute(&mut *transaction) + .await + .map_err(|err| { + tracing::error!("SQLite could not update proof state: {err:?}"); + Error::SQLX(err) + })?; + + transaction.commit().await.map_err(Error::from)?; + Ok(current_state) + } + + async fn add_blind_signatures( + &self, + blinded_messages: &[PublicKey], + blind_signatures: &[BlindSignature], + ) -> Result<(), Self::Err> { + let mut transaction = self.pool.begin().await.map_err(Error::from)?; + for (message, signature) in blinded_messages.iter().zip(blind_signatures) { + let res = sqlx::query( + r#" +INSERT INTO blind_signature +(y, amount, keyset_id, c) +VALUES (?, ?, ?, ?); + "#, + ) + .bind(message.to_bytes().to_vec()) + .bind(u64::from(signature.amount) as i64) + .bind(signature.keyset_id.to_string()) + .bind(signature.c.to_bytes().to_vec()) + .execute(&mut *transaction) + .await; + + if let Err(err) = res { + tracing::error!("SQLite could not add blind signature"); + if let Err(err) = transaction.rollback().await { + tracing::error!("Could not rollback sql transaction: {}", err); + } + return Err(Error::SQLX(err).into()); + } + } + + transaction.commit().await.map_err(Error::from)?; + + Ok(()) + } + + async fn get_blind_signatures( + &self, + blinded_messages: &[PublicKey], + ) -> Result>, Self::Err> { + let mut transaction = self.pool.begin().await.map_err(Error::from)?; + + let sql = format!( + "SELECT * FROM blind_signature WHERE y IN ({})", + "?,".repeat(blinded_messages.len()).trim_end_matches(',') + ); + + let mut blinded_signatures = blinded_messages + .iter() + .fold(sqlx::query(&sql), |query, y| { + query.bind(y.to_bytes().to_vec()) + }) + .fetch_all(&mut *transaction) + .await + .map_err(|err| { + tracing::error!("SQLite could not get state of proof: {err:?}"); + Error::SQLX(err) + })? + .into_iter() + .map(|row| { + PublicKey::from_slice(row.get("y")) + .map_err(Error::from) + .and_then(|y| sqlite_row_to_blind_signature(row).map(|blinded| (y, blinded))) + }) + .collect::, _>>()?; + + Ok(blinded_messages + .iter() + .map(|y| blinded_signatures.remove(y)) + .collect()) + } + + async fn add_protected_endpoints( + &self, + protected_endpoints: HashMap, + ) -> Result<(), Self::Err> { + let mut transaction = self.pool.begin().await.map_err(Error::from)?; + + for (endpoint, auth) in protected_endpoints.iter() { + if let Err(err) = sqlx::query( + r#" +INSERT OR REPLACE INTO protected_endpoints +(endpoint, auth) +VALUES (?, ?); + "#, + ) + .bind(serde_json::to_string(endpoint)?) + .bind(serde_json::to_string(auth)?) + .execute(&mut *transaction) + .await + .map_err(Error::from) + { + tracing::debug!( + "Attempting to add protected endpoint. Skipping.... {:?}", + err + ); + } + } + + transaction.commit().await.map_err(Error::from)?; + + Ok(()) + } + async fn remove_protected_endpoints( + &self, + protected_endpoints: Vec, + ) -> Result<(), Self::Err> { + let mut transaction = self.pool.begin().await.map_err(Error::from)?; + + let sql = format!( + "DELETE FROM protected_endpoints WHERE endpoint IN ({})", + std::iter::repeat("?") + .take(protected_endpoints.len()) + .collect::>() + .join(",") + ); + + let endpoints = protected_endpoints + .iter() + .map(serde_json::to_string) + .collect::, _>>()?; + + endpoints + .iter() + .fold(sqlx::query(&sql), |query, endpoint| query.bind(endpoint)) + .execute(&mut *transaction) + .await + .map_err(Error::from)?; + + transaction.commit().await.map_err(Error::from)?; + Ok(()) + } + async fn get_auth_for_endpoint( + &self, + protected_endpoint: ProtectedEndpoint, + ) -> Result, Self::Err> { + let mut transaction = self.pool.begin().await.map_err(Error::from)?; + + let rec = sqlx::query( + r#" +SELECT * +FROM protected_endpoints +WHERE endpoint=?; + "#, + ) + .bind(serde_json::to_string(&protected_endpoint)?) + .fetch_one(&mut *transaction) + .await; + + match rec { + Ok(rec) => { + transaction.commit().await.map_err(Error::from)?; + + let auth: String = rec.try_get("auth").map_err(Error::from)?; + + Ok(Some(serde_json::from_str(&auth)?)) + } + Err(err) => match err { + sqlx::Error::RowNotFound => { + transaction.commit().await.map_err(Error::from)?; + return Ok(None); + } + _ => { + return { + if let Err(err) = transaction.rollback().await { + tracing::error!("Could not rollback sql transaction: {}", err); + } + Err(Error::SQLX(err).into()) + } + } + }, + } + } + async fn get_auth_for_endpoints( + &self, + ) -> Result>, Self::Err> { + let mut transaction = self.pool.begin().await.map_err(Error::from)?; + + let recs = sqlx::query( + r#" +SELECT * +FROM protected_endpoints + "#, + ) + .fetch_all(&mut *transaction) + .await; + + match recs { + Ok(recs) => { + transaction.commit().await.map_err(Error::from)?; + + let mut endpoints = HashMap::new(); + + for rec in recs { + let auth: String = rec.try_get("auth").map_err(Error::from)?; + let endpoint: String = rec.try_get("endpoint").map_err(Error::from)?; + + let endpoint: ProtectedEndpoint = serde_json::from_str(&endpoint)?; + let auth: AuthRequired = serde_json::from_str(&auth)?; + + endpoints.insert(endpoint, Some(auth)); + } + + Ok(endpoints) + } + Err(err) => { + tracing::error!("SQLite could not get protected endpoints"); + if let Err(err) = transaction.rollback().await { + tracing::error!("Could not rollback sql transaction: {}", err); + } + Err(Error::from(err).into()) + } + } + } +} diff --git a/crates/cdk-sqlite/src/mint/mod.rs b/crates/cdk-sqlite/src/mint/mod.rs index 9af41963..57134291 100644 --- a/crates/cdk-sqlite/src/mint/mod.rs +++ b/crates/cdk-sqlite/src/mint/mod.rs @@ -26,9 +26,14 @@ use uuid::Uuid; use crate::common::create_sqlite_pool; +#[cfg(feature = "auth")] +mod auth; pub mod error; pub mod memory; +#[cfg(feature = "auth")] +pub use auth::MintSqliteAuthDatabase; + /// Mint SQLite Database #[derive(Debug, Clone)] pub struct MintSqliteDatabase { @@ -1565,7 +1570,7 @@ fn sqlite_row_to_keyset_info(row: SqliteRow) -> Result { let row_valid_to: Option = row.try_get("valid_to").map_err(Error::from)?; let row_derivation_path: String = row.try_get("derivation_path").map_err(Error::from)?; let row_max_order: u8 = row.try_get("max_order").map_err(Error::from)?; - let row_keyset_ppk: Option = row.try_get("input_fee_ppk").map_err(Error::from)?; + let row_keyset_ppk: Option = row.try_get("input_fee_ppk").ok(); let row_derivation_path_index: Option = row.try_get("derivation_path_index").map_err(Error::from)?; @@ -1748,7 +1753,7 @@ mod tests { // Create a keyset and add it to the database let keyset_id = Id::from_str("00916bbf7ef91a36").unwrap(); let keyset_info = MintKeySetInfo { - id: keyset_id.clone(), + id: keyset_id, unit: CurrencyUnit::Sat, active: true, valid_from: 0, @@ -1763,7 +1768,7 @@ mod tests { let proofs = vec![ Proof { amount: Amount::from(100), - keyset_id: keyset_id.clone(), + keyset_id, secret: Secret::generate(), c: SecretKey::generate().public_key(), witness: None, @@ -1771,7 +1776,7 @@ mod tests { }, Proof { amount: Amount::from(200), - keyset_id: keyset_id.clone(), + keyset_id, secret: Secret::generate(), c: SecretKey::generate().public_key(), witness: None, @@ -1816,7 +1821,7 @@ mod tests { // Create a keyset and add it to the database let keyset_id = Id::from_str("00916bbf7ef91a36").unwrap(); let keyset_info = MintKeySetInfo { - id: keyset_id.clone(), + id: keyset_id, unit: CurrencyUnit::Sat, active: true, valid_from: 0, @@ -1831,7 +1836,7 @@ mod tests { let proofs = vec![ Proof { amount: Amount::from(100), - keyset_id: keyset_id.clone(), + keyset_id, secret: Secret::generate(), c: SecretKey::generate().public_key(), witness: None, @@ -1839,7 +1844,7 @@ mod tests { }, Proof { amount: Amount::from(200), - keyset_id: keyset_id.clone(), + keyset_id, secret: Secret::generate(), c: SecretKey::generate().public_key(), witness: None, diff --git a/crates/cdk/Cargo.toml b/crates/cdk/Cargo.toml index bd0f8373..eb9e5925 100644 --- a/crates/cdk/Cargo.toml +++ b/crates/cdk/Cargo.toml @@ -11,11 +11,12 @@ license = "MIT" [features] -default = ["mint", "wallet"] -mint = ["dep:futures", "cdk-common/mint"] +default = ["mint", "wallet", "auth"] +wallet = ["dep:reqwest", "cdk-common/wallet"] +mint = ["dep:futures", "dep:reqwest", "cdk-common/mint"] +auth = ["dep:jsonwebtoken", "cdk-common/auth", "cdk-common/auth"] # We do not commit to a MSRV with swagger enabled swagger = ["mint", "dep:utoipa", "cdk-common/swagger"] -wallet = ["dep:reqwest", "cdk-common/wallet"] bench = [] http_subscription = [] @@ -28,7 +29,7 @@ anyhow.workspace = true bitcoin.workspace = true ciborium.workspace = true lightning-invoice.workspace = true -regex = "1" +regex.workspace = true reqwest = { workspace = true, optional = true } serde.workspace = true serde_json.workspace = true @@ -39,6 +40,7 @@ futures = { workspace = true, optional = true, features = ["alloc"] } url.workspace = true utoipa = { workspace = true, optional = true } uuid.workspace = true +jsonwebtoken = { version = "9", optional = true } # -Z minimal-versions sync_wrapper = "0.1.2" @@ -78,12 +80,18 @@ required-features = ["wallet"] name = "proof-selection" required-features = ["wallet"] +[[example]] +name = "auth_wallet" +required-features = ["wallet", "auth"] + [dev-dependencies] -rand = "0.8.5" +rand.workspace = true cdk-sqlite.workspace = true bip39.workspace = true tracing-subscriber.workspace = true criterion = "0.5.1" +reqwest = { workspace = true } + [[bench]] name = "dhke_benchmarks" diff --git a/crates/cdk/README.md b/crates/cdk/README.md index c00aad19..fccd1c78 100644 --- a/crates/cdk/README.md +++ b/crates/cdk/README.md @@ -44,7 +44,7 @@ async fn main() { let localstore = memory::empty().await.unwrap(); - let wallet = Wallet::new(mint_url, unit, Arc::new(localstore), &seed); + let wallet = Wallet::new(mint_url, unit, Arc::new(localstore), &seed, None, None); let quote = wallet.mint_quote(amount).await.unwrap(); diff --git a/crates/cdk/examples/auth_wallet.rs b/crates/cdk/examples/auth_wallet.rs new file mode 100644 index 00000000..988842fc --- /dev/null +++ b/crates/cdk/examples/auth_wallet.rs @@ -0,0 +1,150 @@ +use std::sync::Arc; + +use cdk::amount::SplitTarget; +use cdk::error::Error; +use cdk::nuts::{CurrencyUnit, MintQuoteState, NotificationPayload}; +use cdk::wallet::{SendOptions, Wallet, WalletSubscription}; +use cdk::{Amount, OidcClient}; +use cdk_common::{MintInfo, ProofsMethods}; +use cdk_sqlite::wallet::memory; +use rand::Rng; +use tracing_subscriber::EnvFilter; + +const TEST_USERNAME: &str = "cdk-test"; +const TEST_PASSWORD: &str = "cdkpassword"; + +#[tokio::main] +async fn main() -> Result<(), Error> { + // Set up logging + let default_filter = "debug"; + let sqlx_filter = "sqlx=warn,hyper_util=warn,reqwest=warn,rustls=warn"; + let env_filter = EnvFilter::new(format!("{},{}", default_filter, sqlx_filter)); + tracing_subscriber::fmt().with_env_filter(env_filter).init(); + + // Initialize the memory store for the wallet + let localstore = memory::empty().await?; + + // Generate a random seed for the wallet + let seed = rand::thread_rng().gen::<[u8; 32]>(); + + // Define the mint URL and currency unit + let mint_url = "http://127.0.0.1:8085"; + let unit = CurrencyUnit::Sat; + let amount = Amount::from(50); + + // Create a new wallet + let wallet = Wallet::new(mint_url, unit, Arc::new(localstore), &seed, None)?; + + let mint_info = wallet + .get_mint_info() + .await + .expect("mint info") + .expect("could not get mint info"); + + // Request a mint quote from the wallet + let quote = wallet.mint_quote(amount, None).await; + + println!("Minting nuts ... {:?}", quote); + + // Getting the CAT token is not inscope of cdk and expected to be handled by the implemntor + // We just use this helper fn with password auth for testing + let access_token = get_access_token(&mint_info).await; + + wallet.set_cat(access_token).await.unwrap(); + + wallet + .mint_blind_auth(10.into()) + .await + .expect("Could not mint blind auth"); + + // Request a mint quote from the wallet + let quote = wallet.mint_quote(amount, None).await?; + + // Subscribe to updates on the mint quote state + let mut subscription = wallet + .subscribe(WalletSubscription::Bolt11MintQuoteState(vec![quote + .id + .clone()])) + .await; + + // Wait for the mint quote to be paid + while let Some(msg) = subscription.recv().await { + if let NotificationPayload::MintQuoteBolt11Response(response) = msg { + if response.state == MintQuoteState::Paid { + break; + } + } + } + + // Mint the received amount + let receive_amount = wallet.mint("e.id, SplitTarget::default(), None).await?; + + println!("Received: {}", receive_amount.total_amount()?); + + // Get the total balance of the wallet + let balance = wallet.total_balance().await?; + println!("Wallet balance: {}", balance); + + let prepared_send = wallet + .prepare_send(10.into(), SendOptions::default()) + .await?; + let token = wallet.send(prepared_send, None).await?; + + println!("Created token: {}", token); + + let remaining_blind_auth = wallet.get_unspent_auth_proofs().await?.len(); + + // We started with 10 blind tokens we expect 8 ath this point + // 1 is used for the mint quote + 1 used for the mint + // The swap is not expected to use one as it will be offline or we have "/swap" as an unprotected endpoint in the mint config + assert_eq!(remaining_blind_auth, 8); + + println!("Remaining blind auth: {}", remaining_blind_auth); + + Ok(()) +} + +async fn get_access_token(mint_info: &MintInfo) -> String { + let openid_discovery = mint_info + .nuts + .nut21 + .clone() + .expect("Nut21 defined") + .openid_discovery; + + let oidc_client = OidcClient::new(openid_discovery); + + // Get the token endpoint from the OIDC configuration + let token_url = oidc_client + .get_oidc_config() + .await + .expect("Failed to get OIDC config") + .token_endpoint; + + // Create the request parameters + let params = [ + ("grant_type", "password"), + ("client_id", "cashu-client"), + ("username", TEST_USERNAME), + ("password", TEST_PASSWORD), + ]; + + // Make the token request directly + let client = reqwest::Client::new(); + let response = client + .post(token_url) + .form(¶ms) + .send() + .await + .expect("Failed to send token request"); + + let token_response: serde_json::Value = response + .json() + .await + .expect("Failed to parse token response"); + + token_response["access_token"] + .as_str() + .expect("No access token in response") + .to_string() +} diff --git a/crates/cdk/examples/p2pk.rs b/crates/cdk/examples/p2pk.rs index e476e0a9..9060fec7 100644 --- a/crates/cdk/examples/p2pk.rs +++ b/crates/cdk/examples/p2pk.rs @@ -32,7 +32,7 @@ async fn main() -> Result<(), Error> { let amount = Amount::from(100); // Create a new wallet - let wallet = Wallet::new(mint_url, unit, Arc::new(localstore), &seed, Some(1))?; + let wallet = Wallet::new(mint_url, unit, Arc::new(localstore), &seed, None).unwrap(); // Request a mint quote from the wallet let quote = wallet.mint_quote(amount, None).await?; diff --git a/crates/cdk/examples/proof-selection.rs b/crates/cdk/examples/proof-selection.rs index 29ae6aab..95b60156 100644 --- a/crates/cdk/examples/proof-selection.rs +++ b/crates/cdk/examples/proof-selection.rs @@ -24,7 +24,7 @@ async fn main() -> Result<(), Box> { let localstore = memory::empty().await?; // Create a new wallet - let wallet = Wallet::new(mint_url, unit, Arc::new(localstore), &seed, None)?; + let wallet = Wallet::new(mint_url, unit, Arc::new(localstore), &seed, None).unwrap(); // Amount to mint for amount in [64] { diff --git a/crates/cdk/examples/wallet.rs b/crates/cdk/examples/wallet.rs index c45fd528..c17bd83f 100644 --- a/crates/cdk/examples/wallet.rs +++ b/crates/cdk/examples/wallet.rs @@ -24,7 +24,7 @@ async fn main() -> Result<(), Box> { let localstore = memory::empty().await?; // Create a new wallet - let wallet = Wallet::new(mint_url, unit, Arc::new(localstore), &seed, None)?; + let wallet = Wallet::new(mint_url, unit, Arc::new(localstore), &seed, None).unwrap(); // Request a mint quote from the wallet let quote = wallet.mint_quote(amount, None).await?; diff --git a/crates/cdk/src/lib.rs b/crates/cdk/src/lib.rs index 602f01b4..34684beb 100644 --- a/crates/cdk/src/lib.rs +++ b/crates/cdk/src/lib.rs @@ -6,6 +6,8 @@ pub mod cdk_database { //! CDK Database pub use cdk_common::database::Error; + #[cfg(all(feature = "mint", feature = "auth"))] + pub use cdk_common::database::MintAuthDatabase; #[cfg(feature = "mint")] pub use cdk_common::database::MintDatabase; #[cfg(feature = "wallet")] @@ -17,6 +19,12 @@ pub mod mint; #[cfg(feature = "wallet")] pub mod wallet; +#[cfg(all(any(feature = "wallet", feature = "mint"), feature = "auth"))] +mod oidc_client; + +#[cfg(all(any(feature = "wallet", feature = "mint"), feature = "auth"))] +pub use oidc_client::OidcClient; + pub mod pub_sub; /// Re-export amount type @@ -45,7 +53,7 @@ pub use wallet::{Wallet, WalletSubscription}; pub use self::util::SECP256K1; #[cfg(feature = "wallet")] #[doc(hidden)] -pub use self::wallet::client::HttpClient; +pub use self::wallet::HttpClient; /// Result #[doc(hidden)] diff --git a/crates/cdk/src/mint/auth/mod.rs b/crates/cdk/src/mint/auth/mod.rs new file mode 100644 index 00000000..4d3f5ab8 --- /dev/null +++ b/crates/cdk/src/mint/auth/mod.rs @@ -0,0 +1,413 @@ +use cdk_common::{CurrencyUnit, MintKeySet}; +use tracing::instrument; + +use super::nut21::ProtectedEndpoint; +use super::{ + AuthProof, AuthRequired, AuthToken, BlindAuthToken, BlindSignature, BlindedMessage, Error, Id, + Mint, State, +}; +use crate::dhke::{sign_message, verify_message}; +use crate::Amount; + +impl Mint { + /// Check if and what kind of auth is required for a method + #[instrument(skip(self), fields(endpoint = ?method))] + pub async fn is_protected( + &self, + method: &ProtectedEndpoint, + ) -> Result, Error> { + if let Some(auth_db) = self.auth_localstore.as_ref() { + Ok(auth_db.get_auth_for_endpoint(*method).await?) + } else { + Ok(None) + } + } + + /// Verify Clear auth + #[instrument(skip_all, fields(token_len = token.len()))] + pub async fn verify_clear_auth(&self, token: String) -> Result<(), Error> { + Ok(self + .oidc_client + .as_ref() + .ok_or(Error::OidcNotSet)? + .verify_cat(&token) + .await?) + } + + /// Ensure Keyset is loaded in mint + #[instrument(skip(self))] + pub async fn ensure_blind_auth_keyset_loaded(&self, id: &Id) -> Result { + { + if let Some(keyset) = self.keysets.read().await.get(id) { + return Ok(keyset.clone()); + } + } + + tracing::info!( + "Keyset {:?} not found in memory, attempting to load from storage", + id + ); + + let mut keysets = self.keysets.write().await; + + // Get auth_localstore reference + let auth_localstore = match self.auth_localstore.as_ref() { + Some(store) => store, + None => { + tracing::error!("Auth localstore is not configured"); + return Err(Error::AmountKey); + } + }; + + // Get keyset info from storage + let keyset_info = match auth_localstore.get_keyset_info(id).await { + Ok(Some(info)) => { + tracing::debug!("Found keyset info in storage for ID {:?}", id); + info + } + Ok(None) => { + tracing::error!("Keyset with ID {:?} not found in storage", id); + return Err(Error::KeysetUnknown(*id)); + } + Err(e) => { + tracing::error!("Error retrieving keyset info from storage: {:?}", e); + return Err(e.into()); + } + }; + + let id = keyset_info.id; + tracing::info!("Generating and inserting keyset {:?} into memory", id); + let keyset = self.generate_keyset(keyset_info); + + keysets.insert(id, keyset.clone()); + tracing::debug!("Keyset {:?} successfully loaded", id); + Ok(keyset) + } + + /// Verify Blind auth + #[instrument(skip(self, token))] + pub async fn verify_blind_auth(&self, token: &BlindAuthToken) -> Result<(), Error> { + let proof = &token.auth_proof; + let keyset_id = proof.keyset_id; + + tracing::trace!( + "Starting blind auth verification for keyset ID: {:?}", + keyset_id + ); + + // Ensure the keyset is loaded + let keyset = self + .ensure_blind_auth_keyset_loaded(&keyset_id) + .await + .map_err(|err| { + tracing::error!("Failed to load keyset: {:?}", err); + err + })?; + + // Verify keyset is for auth + if keyset.unit != CurrencyUnit::Auth { + tracing::warn!( + "Blind auth attempted with non-auth keyset. Found unit: {:?}", + keyset.unit + ); + return Err(Error::BlindAuthFailed); + } + + // Get the keypair for amount 1 + let keypair = match keyset.keys.get(&Amount::from(1)) { + Some(key_pair) => key_pair, + None => { + tracing::error!("No keypair found for amount 1 in keyset {:?}", keyset_id); + return Err(Error::AmountKey); + } + }; + + // Verify the message + match verify_message(&keypair.secret_key, proof.c, proof.secret.as_bytes()) { + Ok(_) => { + tracing::trace!( + "Blind signature verification successful for keyset ID: {:?}", + keyset_id + ); + } + Err(e) => { + tracing::error!("Blind signature verification failed: {:?}", e); + return Err(e.into()); + } + } + + Ok(()) + } + + /// Verify Auth + /// + /// If it is a blind auth this will also burn the proof + #[instrument(skip_all)] + pub async fn verify_auth( + &self, + auth_token: Option, + endpoint: &ProtectedEndpoint, + ) -> Result<(), Error> { + if let Some(auth_required) = self.is_protected(endpoint).await? { + tracing::info!( + "Auth required for endpoint: {:?}, type: {:?}", + endpoint, + auth_required + ); + + let auth_token = match auth_token { + Some(token) => token, + None => match auth_required { + AuthRequired::Clear => { + tracing::warn!( + "No auth token provided for protected endpoint: {:?}, expected clear auth.", + endpoint + ); + return Err(Error::ClearAuthRequired); + } + AuthRequired::Blind => { + tracing::warn!( + "No auth token provided for protected endpoint: {:?}, expected blind auth.", + endpoint + ); + return Err(Error::BlindAuthRequired); + } + }, + }; + + match (auth_required, auth_token) { + (AuthRequired::Clear, AuthToken::ClearAuth(token)) => { + tracing::debug!("Verifying clear auth token"); + match self.verify_clear_auth(token.clone()).await { + Ok(_) => tracing::info!("Clear auth verification successful"), + Err(e) => { + tracing::error!("Clear auth verification failed: {:?}", e); + return Err(e); + } + } + } + (AuthRequired::Blind, AuthToken::BlindAuth(token)) => { + tracing::debug!( + "Verifying blind auth token with keyset_id: {:?}", + token.auth_proof.keyset_id + ); + + match self.verify_blind_auth(&token).await { + Ok(_) => tracing::debug!("Blind auth signature verification successful"), + Err(e) => { + tracing::error!("Blind auth verification failed: {:?}", e); + return Err(e); + } + } + + let auth_proof = token.auth_proof; + + self.check_blind_auth_proof_spendable(auth_proof) + .await + .map_err(|err| { + tracing::error!("Failed to spend blind auth proof: {:?}", err); + err + })?; + } + (AuthRequired::Blind, other) => { + tracing::warn!( + "Blind auth required but received different auth type: {:?}", + other + ); + return Err(Error::BlindAuthRequired); + } + (AuthRequired::Clear, other) => { + tracing::warn!( + "Clear auth required but received different auth type: {:?}", + other + ); + return Err(Error::ClearAuthRequired); + } + } + } else { + tracing::debug!("No auth required for endpoint: {:?}", endpoint); + } + + tracing::debug!("Auth verification completed successfully"); + Ok(()) + } + + /// Check state of blind auth proof and mark it as spent + #[instrument(skip_all)] + pub async fn check_blind_auth_proof_spendable(&self, proof: AuthProof) -> Result<(), Error> { + tracing::trace!( + "Checking if blind auth proof is spendable for keyset ID: {:?}", + proof.keyset_id + ); + + // Get auth_localstore reference + let auth_localstore = match self.auth_localstore.as_ref() { + Some(store) => store, + None => { + tracing::error!("Auth localstore is not configured"); + return Err(Error::AmountKey); + } + }; + + // Calculate the Y value for the proof + let y = proof.y().map_err(|err| { + tracing::error!("Failed to calculate Y value for proof: {:?}", err); + err + })?; + + // Add proof to the database + auth_localstore + .add_proof(proof.clone()) + .await + .map_err(|err| { + tracing::error!("Failed to add proof to database: {:?}", err); + err + })?; + + // Update proof state to spent + let state = match auth_localstore.update_proof_state(&y, State::Spent).await { + Ok(state) => { + tracing::debug!( + "Successfully updated proof state to SPENT, previous state: {:?}", + state + ); + state + } + Err(e) => { + tracing::error!("Failed to update proof state: {:?}", e); + return Err(e.into()); + } + }; + + // Check previous state + match state { + Some(State::Spent) => { + tracing::warn!("Token already spent: {:?}", y); + return Err(Error::TokenAlreadySpent); + } + Some(State::Pending) => { + tracing::warn!("Token is pending: {:?}", y); + return Err(Error::TokenPending); + } + Some(other_state) => { + tracing::trace!("Token was in state {:?}, now marked as spent", other_state); + } + None => { + tracing::trace!("Token was in state None, now marked as spent"); + } + }; + + Ok(()) + } + + /// Blind Sign + #[instrument(skip_all)] + pub async fn auth_blind_sign( + &self, + blinded_message: &BlindedMessage, + ) -> Result { + let BlindedMessage { + amount, + blinded_secret, + keyset_id, + .. + } = blinded_message; + + // Ensure the keyset is loaded + let keyset = match self.ensure_blind_auth_keyset_loaded(keyset_id).await { + Ok(keyset) => keyset, + Err(e) => { + tracing::error!("Failed to load keyset: {:?}", e); + return Err(e); + } + }; + + // Get auth_localstore reference + let auth_localstore = match self.auth_localstore.as_ref() { + Some(store) => store, + None => { + tracing::error!("Auth localstore is not configured"); + return Err(Error::AuthSettingsUndefined); + } + }; + + // Get keyset info + let keyset_info = match auth_localstore.get_keyset_info(keyset_id).await { + Ok(Some(info)) => info, + Ok(None) => { + tracing::error!("Keyset with ID {:?} not found in storage", keyset_id); + return Err(Error::UnknownKeySet); + } + Err(e) => { + tracing::error!("Error retrieving keyset info from storage: {:?}", e); + return Err(e.into()); + } + }; + + // Get active keyset ID + let active = match auth_localstore.get_active_keyset_id().await { + Ok(Some(id)) => id, + Ok(None) => { + tracing::error!("No active keyset found"); + return Err(Error::InactiveKeyset); + } + Err(e) => { + tracing::error!("Error retrieving active keyset ID: {:?}", e); + return Err(e.into()); + } + }; + + // Check that the keyset is active and should be used to sign + if keyset_info.id.ne(&active) { + tracing::warn!( + "Keyset {:?} is not active. Active keyset is {:?}", + keyset_info.id, + active + ); + return Err(Error::InactiveKeyset); + } + + // Get the keypair for the specified amount + let key_pair = match keyset.keys.get(amount) { + Some(key_pair) => key_pair, + None => { + tracing::error!( + "No keypair found for amount {:?} in keyset {:?}", + amount, + keyset_id + ); + return Err(Error::AmountKey); + } + }; + + // Sign the message + let c = match sign_message(&key_pair.secret_key, blinded_secret) { + Ok(signature) => signature, + Err(e) => { + tracing::error!("Failed to sign message: {:?}", e); + return Err(e.into()); + } + }; + + // Create blinded signature + let blinded_signature = match BlindSignature::new( + *amount, + c, + keyset_info.id, + &blinded_message.blinded_secret, + key_pair.secret_key.clone(), + ) { + Ok(sig) => sig, + Err(e) => { + tracing::error!("Failed to create blinded signature: {:?}", e); + return Err(e.into()); + } + }; + + tracing::trace!( + "Blind signing completed successfully for keyset ID: {:?}", + keyset_id + ); + Ok(blinded_signature) + } +} diff --git a/crates/cdk/src/mint/builder.rs b/crates/cdk/src/mint/builder.rs index 659e44a0..2f415fce 100644 --- a/crates/cdk/src/mint/builder.rs +++ b/crates/cdk/src/mint/builder.rs @@ -8,11 +8,16 @@ use bitcoin::bip32::DerivationPath; use cdk_common::database::{self, MintDatabase}; use cdk_common::error::Error; use cdk_common::payment::Bolt11Settings; +use cdk_common::{nut21, nut22}; use super::nut17::SupportedMethods; use super::nut19::{self, CachedEndpoint}; +#[cfg(feature = "auth")] +use super::MintAuthDatabase; use super::Nuts; use crate::amount::Amount; +#[cfg(feature = "auth")] +use crate::cdk_database; use crate::cdk_payment::{self, MintPayment}; use crate::mint::Mint; use crate::nuts::{ @@ -28,6 +33,9 @@ pub struct MintBuilder { pub mint_info: MintInfo, /// Mint Storage backend localstore: Option + Send + Sync>>, + /// Mint Storage backend + #[cfg(feature = "auth")] + auth_localstore: Option + Send + Sync>>, /// Ln backends for mint ln: Option< HashMap + Send + Sync>>, @@ -35,6 +43,8 @@ pub struct MintBuilder { seed: Option>, supported_units: HashMap, custom_paths: HashMap, + // protected_endpoints: HashMap, + openid_discovery: Option, } impl MintBuilder { @@ -66,6 +76,22 @@ impl MintBuilder { self } + /// Set auth localstore + #[cfg(feature = "auth")] + pub fn with_auth_localstore( + mut self, + localstore: Arc + Send + Sync>, + ) -> MintBuilder { + self.auth_localstore = Some(localstore); + self + } + + /// Set Openid discovery url + pub fn with_openid_discovery(mut self, openid_discovery: String) -> Self { + self.openid_discovery = Some(openid_discovery); + self + } + /// Set seed pub fn with_seed(mut self, seed: Vec) -> Self { self.seed = Some(seed); @@ -141,6 +167,9 @@ impl MintBuilder { method: method.clone(), }; + tracing::debug!("Adding ln backed for {}, {}", unit, method); + tracing::debug!("with limits {:?}", limits); + let mut ln = self.ln.unwrap_or_default(); let settings = ln_backend.get_settings().await?; @@ -235,17 +264,73 @@ impl MintBuilder { self } + /// Set clear auth settings + pub fn set_clear_auth_settings(mut self, openid_discovery: String, client_id: String) -> Self { + let mut nuts = self.mint_info.nuts; + + nuts.nut21 = Some(nut21::Settings::new( + openid_discovery.clone(), + client_id, + vec![], + )); + + self.openid_discovery = Some(openid_discovery); + + self.mint_info.nuts = nuts; + + self + } + + /// Set blind auth settings + pub fn set_blind_auth_settings(mut self, bat_max_mint: u64) -> Self { + let mut nuts = self.mint_info.nuts; + + nuts.nut22 = Some(nut22::Settings::new(bat_max_mint, vec![])); + + self.mint_info.nuts = nuts; + + self + } + /// Build mint pub async fn build(&self) -> anyhow::Result { let localstore = self .localstore .clone() .ok_or(anyhow!("Localstore not set"))?; + let seed = self.seed.as_ref().ok_or(anyhow!("Mint seed not set"))?; + let ln = self.ln.clone().ok_or(anyhow!("Ln backends not set"))?; + + #[cfg(feature = "auth")] + if let Some(openid_discovery) = &self.openid_discovery { + let auth_localstore = self + .auth_localstore + .clone() + .ok_or(anyhow!("Auth localstore not set"))?; + + return Ok(Mint::new_with_auth( + seed, + localstore, + auth_localstore, + ln, + self.supported_units.clone(), + self.custom_paths.clone(), + openid_discovery.clone(), + ) + .await?); + } + + #[cfg(not(feature = "auth"))] + if self.openid_discovery.is_some() { + return Err(anyhow!( + "OpenID discovery URL provided but auth feature is not enabled" + )); + } Ok(Mint::new( - self.seed.as_ref().ok_or(anyhow!("Mint seed not set"))?, + seed, localstore, - self.ln.clone().ok_or(anyhow!("Ln backends not set"))?, + ln, self.supported_units.clone(), self.custom_paths.clone(), ) diff --git a/crates/cdk/src/mint/issue/auth.rs b/crates/cdk/src/mint/issue/auth.rs new file mode 100644 index 00000000..ccdc1286 --- /dev/null +++ b/crates/cdk/src/mint/issue/auth.rs @@ -0,0 +1,54 @@ +use tracing::instrument; + +use crate::mint::nut22::MintAuthRequest; +use crate::mint::{AuthToken, MintBolt11Response}; +use crate::{Amount, Error, Mint}; + +impl Mint { + /// Mint Auth Proofs + #[instrument(skip_all)] + pub async fn mint_blind_auth( + &self, + auth_token: AuthToken, + mint_auth_request: MintAuthRequest, + ) -> Result { + let cat = if let AuthToken::ClearAuth(cat) = auth_token { + cat + } else { + tracing::debug!("Received blind auth mint without cat"); + return Err(Error::ClearAuthRequired); + }; + + self.verify_clear_auth(cat).await?; + + let auth_settings = self + .mint_info() + .await? + .nuts + .nut22 + .ok_or(Error::AuthSettingsUndefined)?; + + if mint_auth_request.amount() > auth_settings.bat_max_mint { + return Err(Error::AmountOutofLimitRange( + 1.into(), + auth_settings.bat_max_mint.into(), + mint_auth_request.amount().into(), + )); + } + + let mut blind_signatures = Vec::with_capacity(mint_auth_request.outputs.len()); + + for blinded_message in mint_auth_request.outputs.iter() { + if blinded_message.amount != Amount::from(1) { + return Err(Error::AmountKey); + } + + let blind_signature = self.auth_blind_sign(blinded_message).await?; + blind_signatures.push(blind_signature); + } + + Ok(MintBolt11Response { + signatures: blind_signatures, + }) + } +} diff --git a/crates/cdk/src/mint/mint_nut04.rs b/crates/cdk/src/mint/issue/issue_nut04.rs similarity index 96% rename from crates/cdk/src/mint/mint_nut04.rs rename to crates/cdk/src/mint/issue/issue_nut04.rs index ce91268d..bc2b0bb4 100644 --- a/crates/cdk/src/mint/mint_nut04.rs +++ b/crates/cdk/src/mint/issue/issue_nut04.rs @@ -2,15 +2,14 @@ use cdk_common::payment::Bolt11Settings; use tracing::instrument; use uuid::Uuid; -use super::verification::Verification; -use super::{ - nut04, CurrencyUnit, Mint, MintQuote, MintQuoteBolt11Request, MintQuoteBolt11Response, - NotificationPayload, PaymentMethod, PublicKey, +use crate::mint::{ + CurrencyUnit, MintBolt11Request, MintBolt11Response, MintQuote, MintQuoteBolt11Request, + MintQuoteBolt11Response, MintQuoteState, NotificationPayload, PublicKey, Verification, }; -use crate::nuts::MintQuoteState; +use crate::nuts::PaymentMethod; use crate::types::PaymentProcessorKey; use crate::util::unix_time; -use crate::{ensure_cdk, Amount, Error}; +use crate::{ensure_cdk, Amount, Error, Mint}; impl Mint { /// Checks that minting is enabled, request is supported unit and within range @@ -260,8 +259,8 @@ impl Mint { #[instrument(skip_all)] pub async fn process_mint_request( &self, - mint_request: nut04::MintBolt11Request, - ) -> Result { + mint_request: MintBolt11Request, + ) -> Result { let mint_quote = self .localstore .get_mint_quote(&mint_request.quote) @@ -356,7 +355,7 @@ impl Mint { self.pubsub_manager .mint_quote_bolt11_status(mint_quote, MintQuoteState::Issued); - Ok(nut04::MintBolt11Response { + Ok(MintBolt11Response { signatures: blind_signatures, }) } diff --git a/crates/cdk/src/mint/issue/mod.rs b/crates/cdk/src/mint/issue/mod.rs new file mode 100644 index 00000000..9c3f8443 --- /dev/null +++ b/crates/cdk/src/mint/issue/mod.rs @@ -0,0 +1,3 @@ +#[cfg(feature = "auth")] +mod auth; +mod issue_nut04; diff --git a/crates/cdk/src/mint/keysets/auth.rs b/crates/cdk/src/mint/keysets/auth.rs new file mode 100644 index 00000000..2650362a --- /dev/null +++ b/crates/cdk/src/mint/keysets/auth.rs @@ -0,0 +1,65 @@ +//! Auth keyset functions + +use tracing::instrument; + +use crate::mint::{CurrencyUnit, Id, KeySetInfo, KeysResponse, KeysetResponse}; +use crate::{Error, Mint}; + +impl Mint { + /// Retrieve the auth public keys of the active keyset for distribution to wallet + /// clients + #[instrument(skip_all)] + pub async fn auth_pubkeys(&self) -> Result { + let active_keyset_id = self + .auth_localstore + .as_ref() + .ok_or(Error::AuthLocalstoreUndefined)? + .get_active_keyset_id() + .await? + .ok_or(Error::AmountKey)?; + + self.ensure_blind_auth_keyset_loaded(&active_keyset_id) + .await?; + + let keysets = self.keysets.read().await; + + Ok(KeysResponse { + keysets: vec![keysets + .get(&active_keyset_id) + .ok_or(Error::KeysetUnknown(active_keyset_id))? + .clone() + .into()], + }) + } + + /// Return a list of auth keysets + #[instrument(skip_all)] + pub async fn auth_keysets(&self) -> Result { + let keysets = self + .auth_localstore + .clone() + .ok_or(Error::AuthLocalstoreUndefined)? + .get_keyset_infos() + .await?; + let active_keysets: Id = self + .auth_localstore + .as_ref() + .ok_or(Error::AuthLocalstoreUndefined)? + .get_active_keyset_id() + .await? + .ok_or(Error::NoActiveKeyset)?; + + let keysets = keysets + .into_iter() + .filter(|k| k.unit == CurrencyUnit::Auth) + .map(|k| KeySetInfo { + id: k.id, + unit: k.unit, + active: active_keysets == k.id, + input_fee_ppk: k.input_fee_ppk, + }) + .collect(); + + Ok(KeysetResponse { keysets }) + } +} diff --git a/crates/cdk/src/mint/keysets.rs b/crates/cdk/src/mint/keysets/mod.rs similarity index 97% rename from crates/cdk/src/mint/keysets.rs rename to crates/cdk/src/mint/keysets/mod.rs index b8ed6ac9..13a357e5 100644 --- a/crates/cdk/src/mint/keysets.rs +++ b/crates/cdk/src/mint/keysets/mod.rs @@ -13,6 +13,9 @@ use super::{ }; use crate::Error; +#[cfg(feature = "auth")] +mod auth; + impl Mint { /// Initialize keysets and returns a [`Result`] with a tuple of the following: /// * a [`HashMap`] mapping each active keyset `Id` to `MintKeySet` @@ -138,7 +141,10 @@ impl Mint { /// clients #[instrument(skip_all)] pub async fn pubkeys(&self) -> Result { - let active_keysets = self.localstore.get_active_keysets().await?; + let mut active_keysets = self.localstore.get_active_keysets().await?; + + // We don't want to return auth keys here even though in the db we treat them the same + active_keysets.remove(&CurrencyUnit::Auth); let active_keysets: HashSet<&Id> = active_keysets.values().collect(); @@ -174,6 +180,7 @@ impl Mint { let keysets = keysets .into_iter() + .filter(|k| k.unit != CurrencyUnit::Auth) .map(|k| KeySetInfo { id: k.id, unit: k.unit, diff --git a/crates/cdk/src/mint/mod.rs b/crates/cdk/src/mint/mod.rs index 25f80518..5afac4be 100644 --- a/crates/cdk/src/mint/mod.rs +++ b/crates/cdk/src/mint/mod.rs @@ -6,9 +6,13 @@ use std::sync::Arc; use bitcoin::bip32::{ChildNumber, DerivationPath, Xpriv}; use bitcoin::secp256k1::{self, Secp256k1}; use cdk_common::common::{PaymentProcessorKey, QuoteTTL}; +#[cfg(feature = "auth")] +use cdk_common::database::MintAuthDatabase; use cdk_common::database::{self, MintDatabase}; use cdk_common::mint::MintKeySetInfo; use futures::StreamExt; +#[cfg(feature = "auth")] +use nut21::ProtectedEndpoint; use subscription::PubSubManager; use tokio::sync::{Notify, RwLock}; use tokio::task::JoinSet; @@ -21,14 +25,18 @@ use crate::error::Error; use crate::fees::calculate_fee; use crate::nuts::*; use crate::util::unix_time; +#[cfg(feature = "auth")] +use crate::OidcClient; use crate::{ensure_cdk, Amount}; +#[cfg(feature = "auth")] +pub(crate) mod auth; mod builder; mod check_spendable; +mod issue; mod keysets; mod ln; mod melt; -mod mint_nut04; mod start_up_check; pub mod subscription; mod swap; @@ -36,17 +44,23 @@ mod verification; pub use builder::{MintBuilder, MintMeltLimits}; pub use cdk_common::mint::{MeltQuote, MintQuote}; +pub use verification::Verification; /// Cashu Mint #[derive(Clone)] pub struct Mint { /// Mint Storage backend pub localstore: Arc + Send + Sync>, + /// Auth Storage backend (only available with auth feature) + #[cfg(feature = "auth")] + pub auth_localstore: Option + Send + Sync>>, /// Ln backends for mint pub ln: HashMap + Send + Sync>>, /// Subscription manager pub pubsub_manager: Arc, + #[cfg(feature = "auth")] + oidc_client: Option, secp_ctx: Secp256k1, xpriv: Xpriv, keysets: Arc>>, @@ -54,8 +68,7 @@ pub struct Mint { } impl Mint { - /// Create new [`Mint`] - #[allow(clippy::too_many_arguments)] + /// Create new [`Mint`] without authentication pub async fn new( seed: &[u8], localstore: Arc + Send + Sync>, @@ -63,9 +76,63 @@ impl Mint { PaymentProcessorKey, Arc + Send + Sync>, >, - // Hashmap where the key is the unit and value is (input fee ppk, max_order) supported_units: HashMap, custom_paths: HashMap, + ) -> Result { + Self::new_internal( + seed, + localstore, + #[cfg(feature = "auth")] + None, + ln, + supported_units, + custom_paths, + #[cfg(feature = "auth")] + None, + ) + .await + } + + /// Create new [`Mint`] with authentication support + #[cfg(feature = "auth")] + pub async fn new_with_auth( + seed: &[u8], + localstore: Arc + Send + Sync>, + auth_localstore: Arc + Send + Sync>, + ln: HashMap< + PaymentProcessorKey, + Arc + Send + Sync>, + >, + supported_units: HashMap, + custom_paths: HashMap, + open_id_discovery: String, + ) -> Result { + Self::new_internal( + seed, + localstore, + Some(auth_localstore), + ln, + supported_units, + custom_paths, + Some(open_id_discovery), + ) + .await + } + + /// Internal function to create a new [`Mint`] with shared logic + async fn new_internal( + seed: &[u8], + localstore: Arc + Send + Sync>, + #[cfg(feature = "auth")] auth_localstore: Option< + Arc + Send + Sync>, + >, + ln: HashMap< + PaymentProcessorKey, + Arc + Send + Sync>, + >, + supported_units: HashMap, + custom_paths: HashMap, + #[cfg(feature = "auth")] open_id_discovery: Option, ) -> Result { let secp_ctx = Secp256k1::new(); let xpriv = Xpriv::new_master(bitcoin::Network::Bitcoin, seed).expect("RNG busted"); @@ -106,6 +173,49 @@ impl Mint { } } + #[cfg(feature = "auth")] + let oidc_client = if let Some(openid_discovery) = open_id_discovery { + { + tracing::info!("Auth enabled creating auth keysets"); + let auth_localstore = auth_localstore + .as_ref() + .ok_or(Error::AuthSettingsUndefined)?; + + let derivation_path = match custom_paths.get(&CurrencyUnit::Auth) { + Some(path) => path.clone(), + None => derivation_path_from_unit(CurrencyUnit::Auth, 0) + .ok_or(Error::UnsupportedUnit)?, + }; + + let (keyset, keyset_info) = create_new_keyset( + &secp_ctx, + xpriv, + derivation_path, + Some(0), + CurrencyUnit::Auth, + 1, + 0, + ); + + let id = keyset_info.id; + auth_localstore.add_keyset_info(keyset_info).await?; + auth_localstore.set_active_keyset(id).await?; + active_keysets.insert(id, keyset); + + Some(OidcClient::new(openid_discovery.clone())) + } + + #[cfg(not(feature = "auth"))] + { + tracing::error!("CDK must be compiled with auth feature to be used with auth."); + return Err(Error::Custom( + "Openid passed but cdk compiled without auth.".to_string(), + )); + } + } else { + None + }; + let keysets = Arc::new(RwLock::new(active_keysets)); Ok(Self { @@ -113,16 +223,56 @@ impl Mint { secp_ctx, xpriv, localstore, + #[cfg(feature = "auth")] + oidc_client, ln, - keysets, custom_paths, + #[cfg(feature = "auth")] + auth_localstore, + keysets, }) } /// Get mint info #[instrument(skip_all)] pub async fn mint_info(&self) -> Result { - Ok(self.localstore.get_mint_info().await?) + let mint_info = self.localstore.get_mint_info().await?; + + #[cfg(feature = "auth")] + let mint_info = if let Some(auth_db) = self.auth_localstore.as_ref() { + let mut mint_info = mint_info; + let auth_endpoints = auth_db.get_auth_for_endpoints().await?; + + let mut clear_auth_endpoints: Vec = vec![]; + let mut blind_auth_endpoints: Vec = vec![]; + + for (endpoint, auth) in auth_endpoints { + match auth { + Some(AuthRequired::Clear) => { + clear_auth_endpoints.push(endpoint); + } + Some(AuthRequired::Blind) => { + blind_auth_endpoints.push(endpoint); + } + None => (), + } + } + + mint_info.nuts.nut21 = mint_info.nuts.nut21.map(|mut a| { + a.protected_endpoints = clear_auth_endpoints; + a + }); + + mint_info.nuts.nut22 = mint_info.nuts.nut22.map(|mut a| { + a.protected_endpoints = blind_auth_endpoints; + a + }); + mint_info + } else { + mint_info + }; + + Ok(mint_info) } /// Set mint info diff --git a/crates/cdk/src/mint/verification.rs b/crates/cdk/src/mint/verification.rs index bc43149c..7d598aa3 100644 --- a/crates/cdk/src/mint/verification.rs +++ b/crates/cdk/src/mint/verification.rs @@ -5,9 +5,12 @@ use tracing::instrument; use super::{Error, Mint}; +/// Verification result #[derive(Debug, Clone, Hash, PartialEq, Eq)] pub struct Verification { + /// Value in request pub amount: Amount, + /// Unit of request pub unit: Option, } diff --git a/crates/cdk/src/oidc_client.rs b/crates/cdk/src/oidc_client.rs new file mode 100644 index 00000000..2f742d49 --- /dev/null +++ b/crates/cdk/src/oidc_client.rs @@ -0,0 +1,240 @@ +//! Open Id Connect + +use std::collections::HashMap; +use std::ops::Deref; +use std::sync::Arc; + +use jsonwebtoken::jwk::{AlgorithmParameters, JwkSet}; +use jsonwebtoken::{decode, decode_header, DecodingKey, Validation}; +use reqwest::Client; +use serde::Deserialize; +#[cfg(feature = "wallet")] +use serde::Serialize; +use thiserror::Error; +use tokio::sync::RwLock; +use tracing::instrument; + +/// OIDC Error +#[derive(Debug, Error)] +pub enum Error { + /// From Reqwest error + #[error(transparent)] + Reqwest(#[from] reqwest::Error), + /// From Reqwest error + #[error(transparent)] + Jwt(#[from] jsonwebtoken::errors::Error), + /// Missing kid header + #[error("Missing kid header")] + MissingKidHeader, + /// Missing jwk header + #[error("Missing jwk")] + MissingJwkHeader, + /// Unsupported Algo + #[error("Unsupported signing algo")] + UnsupportedSigningAlgo, + /// Access token not returned + #[error("Error getting access token")] + AccessTokenMissing, +} + +impl From for cdk_common::error::Error { + fn from(value: Error) -> Self { + tracing::debug!("Clear auth verification failed: {}", value); + cdk_common::error::Error::ClearAuthFailed + } +} + +/// Open Id Config +#[derive(Debug, Clone, Deserialize)] +pub struct OidcConfig { + pub jwks_uri: String, + pub issuer: String, + pub token_endpoint: String, + pub device_authorization_endpoint: String, +} + +/// Http Client +#[derive(Debug, Clone)] +pub struct OidcClient { + client: Client, + openid_discovery: String, + oidc_config: Arc>>, + jwks_set: Arc>>, +} + +#[cfg(feature = "wallet")] +#[derive(Debug, Clone, Copy, Serialize)] +#[serde(rename_all = "snake_case")] +pub enum GrantType { + RefreshToken, +} + +#[cfg(feature = "wallet")] +#[derive(Debug, Clone, Serialize)] +pub struct AccessTokenRequest { + pub grant_type: GrantType, + pub client_id: String, + pub username: String, + pub password: String, +} + +#[cfg(feature = "wallet")] +#[derive(Debug, Clone, Serialize)] +pub struct RefreshTokenRequest { + pub grant_type: GrantType, + pub client_id: String, + pub refresh_token: String, +} + +#[cfg(feature = "wallet")] +#[derive(Debug, Clone, Deserialize)] +pub struct TokenResponse { + pub access_token: String, + pub refresh_token: Option, + pub expires_in: Option, + pub token_type: String, +} + +impl OidcClient { + /// Create new [`OidcClient`] + pub fn new(openid_discovery: String) -> Self { + Self { + client: Client::new(), + openid_discovery, + oidc_config: Arc::new(RwLock::new(None)), + jwks_set: Arc::new(RwLock::new(None)), + } + } + + /// Get config from oidc server + #[instrument(skip(self))] + pub async fn get_oidc_config(&self) -> Result { + tracing::debug!("Getting oidc config"); + let oidc_config = self + .client + .get(&self.openid_discovery) + .send() + .await? + .json::() + .await?; + + let mut current_config = self.oidc_config.write().await; + + *current_config = Some(oidc_config.clone()); + + Ok(oidc_config) + } + + /// Get jwk set + #[instrument(skip(self))] + pub async fn get_jwkset(&self, jwks_uri: &str) -> Result { + tracing::debug!("Getting jwks set"); + let jwks_set = self + .client + .get(jwks_uri) + .send() + .await? + .json::() + .await?; + + let mut current_set = self.jwks_set.write().await; + + *current_set = Some(jwks_set.clone()); + + Ok(jwks_set) + } + + /// Verify cat token + #[instrument(skip_all)] + pub async fn verify_cat(&self, cat_jwt: &str) -> Result<(), Error> { + tracing::debug!("Verifying cat"); + let header = decode_header(cat_jwt)?; + + let kid = header.kid.ok_or(Error::MissingKidHeader)?; + + let oidc_config = { + let locked = self.oidc_config.read().await; + match locked.deref() { + Some(config) => config.clone(), + None => { + drop(locked); + self.get_oidc_config().await? + } + } + }; + + let jwks = { + let locked = self.jwks_set.read().await; + match locked.deref() { + Some(set) => set.clone(), + None => { + drop(locked); + self.get_jwkset(&oidc_config.jwks_uri).await? + } + } + }; + + let jwk = match jwks.find(&kid) { + Some(jwk) => jwk.clone(), + None => { + let refreshed_jwks = self.get_jwkset(&oidc_config.jwks_uri).await?; + refreshed_jwks + .find(&kid) + .ok_or(Error::MissingKidHeader)? + .clone() + } + }; + + let decoding_key = match &jwk.algorithm { + AlgorithmParameters::RSA(rsa) => DecodingKey::from_rsa_components(&rsa.n, &rsa.e)?, + AlgorithmParameters::EllipticCurve(ecdsa) => { + DecodingKey::from_ec_components(&ecdsa.x, &ecdsa.y)? + } + _ => return Err(Error::UnsupportedSigningAlgo), + }; + + let validation = { + let mut validation = Validation::new(header.alg); + validation.validate_exp = true; + validation.validate_aud = false; + validation.set_issuer(&[oidc_config.issuer]); + validation + }; + + if let Err(err) = + decode::>(cat_jwt, &decoding_key, &validation) + { + tracing::debug!("Could not verify cat: {}", err); + return Err(err.into()); + } + + Ok(()) + } + + /// Get new access token using refresh token + #[cfg(feature = "wallet")] + pub async fn refresh_access_token( + &self, + client_id: String, + refresh_token: String, + ) -> Result { + let token_url = self.get_oidc_config().await?.token_endpoint; + + let request = RefreshTokenRequest { + grant_type: GrantType::RefreshToken, + client_id, + refresh_token, + }; + + let response = self + .client + .post(token_url) + .form(&request) + .send() + .await? + .json::() + .await?; + + Ok(response) + } +} diff --git a/crates/cdk/src/wallet/auth/auth_connector.rs b/crates/cdk/src/wallet/auth/auth_connector.rs new file mode 100644 index 00000000..e662bf9b --- /dev/null +++ b/crates/cdk/src/wallet/auth/auth_connector.rs @@ -0,0 +1,30 @@ +use std::fmt::Debug; + +use async_trait::async_trait; +use cdk_common::{AuthToken, MintInfo}; + +use super::Error; +use crate::nuts::{Id, KeySet, KeysetResponse, MintAuthRequest, MintBolt11Response}; + +/// Interface that connects a wallet to a mint. Typically represents an [HttpClient]. +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] +pub trait AuthMintConnector: Debug { + /// Get the current auth token + async fn get_auth_token(&self) -> Result; + + /// Set a new auth token + async fn set_auth_token(&self, token: AuthToken) -> Result<(), Error>; + + /// Get Mint Info [NUT-06] + async fn get_mint_info(&self) -> Result; + /// Get Blind Auth Keyset + async fn get_mint_blind_auth_keyset(&self, keyset_id: Id) -> Result; + /// Get Blind Auth keysets + async fn get_mint_blind_auth_keysets(&self) -> Result; + /// Post mint blind auth + async fn post_mint_blind_auth( + &self, + request: MintAuthRequest, + ) -> Result; +} diff --git a/crates/cdk/src/wallet/auth/auth_wallet.rs b/crates/cdk/src/wallet/auth/auth_wallet.rs new file mode 100644 index 00000000..778e9006 --- /dev/null +++ b/crates/cdk/src/wallet/auth/auth_wallet.rs @@ -0,0 +1,427 @@ +use std::collections::HashMap; +use std::sync::Arc; + +use cdk_common::database::{self, WalletDatabase}; +use cdk_common::mint_url::MintUrl; +use cdk_common::{AuthProof, Id, Keys, MintInfo}; +use serde::{Deserialize, Serialize}; +use tokio::sync::RwLock; +use tracing::instrument; + +use super::AuthMintConnector; +use crate::amount::SplitTarget; +use crate::dhke::construct_proofs; +use crate::nuts::nut22::MintAuthRequest; +use crate::nuts::{ + nut12, AuthRequired, AuthToken, BlindAuthToken, CurrencyUnit, KeySetInfo, PreMintSecrets, + Proofs, ProtectedEndpoint, State, +}; +use crate::types::ProofInfo; +use crate::wallet::mint_connector::AuthHttpClient; +use crate::{Amount, Error, OidcClient}; + +/// JWT Claims structure for decoding tokens +#[derive(Debug, Serialize, Deserialize)] +struct Claims { + /// Subject + sub: Option, + /// Expiration time (as UTC timestamp) + exp: Option, + /// Issued at (as UTC timestamp) + iat: Option, +} +/// CDK Auth Wallet +/// +/// A [`AuthWallet`] is for auth operations with a single mint. +#[derive(Debug, Clone)] +pub struct AuthWallet { + /// Mint Url + pub mint_url: MintUrl, + /// Storage backend + pub localstore: Arc + Send + Sync>, + /// Protected methods + pub protected_endpoints: Arc>>, + /// Refresh token for auth + refresh_token: Arc>>, + client: Arc, + /// OIDC client for authentication + oidc_client: Arc>>, +} + +impl AuthWallet { + /// Create a new [`AuthWallet`] instance + pub fn new( + mint_url: MintUrl, + cat: Option, + localstore: Arc + Send + Sync>, + protected_endpoints: HashMap, + oidc_client: Option, + ) -> Self { + let http_client = Arc::new(AuthHttpClient::new(mint_url.clone(), cat)); + Self { + mint_url, + localstore, + protected_endpoints: Arc::new(RwLock::new(protected_endpoints)), + refresh_token: Arc::new(RwLock::new(None)), + client: http_client, + oidc_client: Arc::new(RwLock::new(oidc_client)), + } + } + + /// Get the current auth token + #[instrument(skip(self))] + pub async fn get_auth_token(&self) -> Result { + self.client.get_auth_token().await + } + + /// Set a new auth token + #[instrument(skip_all)] + pub async fn verify_cat(&self, token: AuthToken) -> Result<(), Error> { + match &token { + AuthToken::ClearAuth(clear_token) => { + if let Some(oidc) = self.oidc_client.read().await.as_ref() { + oidc.verify_cat(clear_token).await?; + } + Ok(()) + } + AuthToken::BlindAuth(_) => Err(Error::Custom( + "Cannot set blind auth token directly".to_string(), + )), + } + } + + /// Set a new auth token + #[instrument(skip_all)] + pub async fn set_auth_token(&self, token: AuthToken) -> Result<(), Error> { + match &token { + AuthToken::ClearAuth(clear_token) => { + if let Some(oidc) = self.oidc_client.read().await.as_ref() { + oidc.verify_cat(clear_token).await?; + } + self.client.set_auth_token(token).await + } + AuthToken::BlindAuth(_) => Err(Error::Custom( + "Cannot set blind auth token directly".to_string(), + )), + } + } + + /// Get the current refresh token if one exists + #[instrument(skip(self))] + pub async fn get_refresh_token(&self) -> Option { + self.refresh_token.read().await.clone() + } + + /// Set a new refresh token + #[instrument(skip(self))] + pub async fn set_refresh_token(&self, token: Option) { + *self.refresh_token.write().await = token; + } + + /// Get the OIDC client if one exists + #[instrument(skip(self))] + pub async fn get_oidc_client(&self) -> Option { + self.oidc_client.read().await.clone() + } + + /// Set a new OIDC client + #[instrument(skip(self))] + pub async fn set_oidc_client(&self, client: Option) { + *self.oidc_client.write().await = client; + } + + /// Refresh the access token using the stored refresh token + #[instrument(skip(self))] + pub async fn refresh_access_token(&self) -> Result<(), Error> { + if let Some(oidc) = self.oidc_client.read().await.as_ref() { + if let Some(refresh_token) = self.get_refresh_token().await { + let mint_info = self + .get_mint_info() + .await? + .ok_or(Error::CouldNotGetMintInfo)?; + let token_response = oidc + .refresh_access_token( + mint_info.client_id().ok_or(Error::CouldNotGetMintInfo)?, + refresh_token, + ) + .await?; + + // Store new refresh token if provided + self.set_refresh_token(token_response.refresh_token).await; + + // Set new access token + self.set_auth_token(AuthToken::ClearAuth(token_response.access_token)) + .await?; + + return Ok(()); + } + } + Err(Error::Custom( + "No refresh token or OIDC client available".to_string(), + )) + } + + /// Query mint for current mint information + #[instrument(skip(self))] + pub async fn get_mint_info(&self) -> Result, Error> { + self.client.get_mint_info().await.map(Some).or(Ok(None)) + } + + /// Get keys for mint keyset + /// + /// Selected keys from localstore if they are already known + /// If they are not known queries mint for keyset id and stores the [`Keys`] + #[instrument(skip(self))] + pub async fn get_keyset_keys(&self, keyset_id: Id) -> Result { + let keys = if let Some(keys) = self.localstore.get_keys(&keyset_id).await? { + keys + } else { + let keys = self.client.get_mint_blind_auth_keyset(keyset_id).await?; + + keys.verify_id()?; + + self.localstore.add_keys(keys.keys.clone()).await?; + + keys.keys + }; + + Ok(keys) + } + + /// Get active keyset for mint + /// + /// Queries mint for current keysets then gets [`Keys`] for any unknown + /// keysets + #[instrument(skip(self))] + pub async fn get_active_mint_blind_auth_keysets(&self) -> Result, Error> { + let keysets = self.client.get_mint_blind_auth_keysets().await?; + let keysets = keysets.keysets; + + self.localstore + .add_mint_keysets(self.mint_url.clone(), keysets.clone()) + .await?; + + let active_keysets = keysets + .clone() + .into_iter() + .filter(|k| k.unit == CurrencyUnit::Auth) + .collect::>(); + + match self + .localstore + .get_mint_keysets(self.mint_url.clone()) + .await? + { + Some(known_keysets) => { + let unknown_keysets: Vec<&KeySetInfo> = keysets + .iter() + .filter(|k| known_keysets.contains(k)) + .collect(); + + for keyset in unknown_keysets { + self.get_keyset_keys(keyset.id).await?; + } + } + None => { + for keyset in keysets { + self.get_keyset_keys(keyset.id).await?; + } + } + } + Ok(active_keysets) + } + + /// Get active keyset for mint + /// + /// Queries mint for current keysets then gets [`Keys`] for any unknown + /// keysets + #[instrument(skip(self))] + pub async fn get_active_mint_blind_auth_keyset(&self) -> Result { + let active_keysets = self.get_active_mint_blind_auth_keysets().await?; + + let keyset = active_keysets.first().ok_or(Error::NoActiveKeyset)?; + Ok(keyset.clone()) + } + + /// Get unspent proofs for mint + #[instrument(skip(self))] + pub async fn get_unspent_auth_proofs(&self) -> Result, Error> { + Ok(self + .localstore + .get_proofs( + Some(self.mint_url.clone()), + Some(CurrencyUnit::Auth), + Some(vec![State::Unspent]), + None, + ) + .await? + .into_iter() + .map(|p| p.proof.try_into()) + .collect::, _>>()?) + } + + /// Check if and what kind of auth is required for a method + #[instrument(skip(self))] + pub async fn is_protected(&self, method: &ProtectedEndpoint) -> Option { + let protected_endpoints = self.protected_endpoints.read().await; + + protected_endpoints.get(method).copied() + } + + /// Get Auth Token + #[instrument(skip(self))] + pub async fn get_blind_auth_token(&self) -> Result, Error> { + let unspent = self.get_unspent_auth_proofs().await?; + + let auth_proof = match unspent.first() { + Some(proof) => { + self.localstore + .update_proofs(vec![], vec![proof.y()?]) + .await?; + proof + } + None => return Ok(None), + }; + + Ok(Some(AuthToken::BlindAuth(BlindAuthToken { + auth_proof: auth_proof.clone(), + }))) + } + + /// Auth for request + #[instrument(skip(self))] + pub async fn get_auth_for_request( + &self, + method: &ProtectedEndpoint, + ) -> Result, Error> { + match self.is_protected(method).await { + Some(auth) => match auth { + AuthRequired::Clear => self.client.get_auth_token().await.map(Some), + AuthRequired::Blind => { + let proof = self + .get_blind_auth_token() + .await? + .ok_or(Error::InsufficientBlindAuthTokens)?; + + Ok(Some(proof)) + } + }, + None => Ok(None), + } + } + + /// Mint blind auth + #[instrument(skip(self))] + pub async fn mint_blind_auth(&self, amount: Amount) -> Result { + tracing::debug!("Minting {} blind auth proofs", amount); + // Check that mint is in store of mints + if self + .localstore + .get_mint(self.mint_url.clone()) + .await? + .is_none() + { + self.get_mint_info().await?; + } + + let auth_token = self.client.get_auth_token().await?; + + let active_keyset_id = self.get_active_mint_blind_auth_keysets().await?; + tracing::debug!("Active ketset: {:?}", active_keyset_id); + + match &auth_token { + AuthToken::ClearAuth(cat) => { + if cat.is_empty() { + tracing::warn!("Auth Cat is not set"); + return Err(Error::ClearAuthRequired); + } + + if let Err(err) = self.verify_cat(auth_token).await { + tracing::warn!("Current cat is invalid {}", err); + } + + let has_refresh; + + { + has_refresh = self.refresh_token.read().await.is_some(); + } + + if has_refresh { + tracing::info!("Attempting to refresh using refresh token"); + self.refresh_access_token().await?; + } else { + tracing::warn!( + "Wallet cat is invalid and there is no refresh token please reauth" + ); + } + } + AuthToken::BlindAuth(_) => { + tracing::error!("Blind auth set as client cat"); + return Err(Error::ClearAuthFailed); + } + } + + let active_keyset_id = self.get_active_mint_blind_auth_keyset().await?.id; + + let premint_secrets = + PreMintSecrets::random(active_keyset_id, amount, &SplitTarget::Value(1.into()))?; + + let request = MintAuthRequest { + outputs: premint_secrets.blinded_messages(), + }; + + let mint_res = self.client.post_mint_blind_auth(request).await?; + + let keys = self.get_keyset_keys(active_keyset_id).await?; + + // Verify the signature DLEQ is valid + { + assert!(mint_res.signatures.len() == premint_secrets.secrets.len()); + for (sig, premint) in mint_res.signatures.iter().zip(&premint_secrets.secrets) { + let keys = self.get_keyset_keys(sig.keyset_id).await?; + let key = keys.amount_key(sig.amount).ok_or(Error::AmountKey)?; + match sig.verify_dleq(key, premint.blinded_message.blinded_secret) { + Ok(_) => (), + Err(nut12::Error::MissingDleqProof) => { + tracing::warn!("Signature for bat returned without dleq proof."); + return Err(Error::DleqProofNotProvided); + } + Err(_) => return Err(Error::CouldNotVerifyDleq), + } + } + } + + let proofs = construct_proofs( + mint_res.signatures, + premint_secrets.rs(), + premint_secrets.secrets(), + &keys, + )?; + + let proof_infos = proofs + .clone() + .into_iter() + .map(|proof| { + ProofInfo::new( + proof, + self.mint_url.clone(), + State::Unspent, + crate::nuts::CurrencyUnit::Auth, + ) + }) + .collect::, _>>()?; + + // Add new proofs to store + self.localstore.update_proofs(proof_infos, vec![]).await?; + + Ok(proofs) + } + + /// Total unspent balance of wallet + #[instrument(skip(self))] + pub async fn total_blind_auth_balance(&self) -> Result { + Ok(Amount::from( + self.get_unspent_auth_proofs().await?.len() as u64 + )) + } +} diff --git a/crates/cdk/src/wallet/auth/mod.rs b/crates/cdk/src/wallet/auth/mod.rs new file mode 100644 index 00000000..bb5abed2 --- /dev/null +++ b/crates/cdk/src/wallet/auth/mod.rs @@ -0,0 +1,68 @@ +mod auth_connector; +mod auth_wallet; + +pub use auth_connector::AuthMintConnector; +pub use auth_wallet::AuthWallet; +use cdk_common::{Amount, AuthProof, AuthToken, Proofs}; +use tracing::instrument; + +use super::Wallet; +use crate::error::Error; + +impl Wallet { + /// Mint blind auth tokens + #[instrument(skip_all)] + pub async fn mint_blind_auth(&self, amount: Amount) -> Result { + self.auth_wallet + .read() + .await + .as_ref() + .ok_or(Error::AuthSettingsUndefined)? + .mint_blind_auth(amount) + .await + } + + /// Get unspent auth proofs + #[instrument(skip_all)] + pub async fn get_unspent_auth_proofs(&self) -> Result, Error> { + self.auth_wallet + .read() + .await + .as_ref() + .ok_or(Error::AuthSettingsUndefined)? + .get_unspent_auth_proofs() + .await + } + + /// Set Clear Auth Token (CAT) for authentication + #[instrument(skip_all)] + pub async fn set_cat(&self, cat: String) -> Result<(), Error> { + let auth_wallet = self.auth_wallet.read().await; + if let Some(auth_wallet) = auth_wallet.as_ref() { + auth_wallet + .set_auth_token(AuthToken::ClearAuth(cat)) + .await?; + } + Ok(()) + } + + /// Set refresh for authentication + #[instrument(skip_all)] + pub async fn set_refresh_token(&self, refresh_token: String) -> Result<(), Error> { + let auth_wallet = self.auth_wallet.read().await; + if let Some(auth_wallet) = auth_wallet.as_ref() { + auth_wallet.set_refresh_token(Some(refresh_token)).await; + } + Ok(()) + } + + /// Refresh CAT token + #[instrument(skip(self))] + pub async fn refresh_access_token(&self) -> Result<(), Error> { + let auth_wallet = self.auth_wallet.read().await; + if let Some(auth_wallet) = auth_wallet.as_ref() { + auth_wallet.refresh_access_token().await?; + } + Ok(()) + } +} diff --git a/crates/cdk/src/wallet/builder.rs b/crates/cdk/src/wallet/builder.rs new file mode 100644 index 00000000..1168e102 --- /dev/null +++ b/crates/cdk/src/wallet/builder.rs @@ -0,0 +1,161 @@ +#[cfg(feature = "auth")] +use std::collections::HashMap; +use std::sync::Arc; + +use bitcoin::bip32::Xpriv; +use bitcoin::Network; +use cdk_common::database; +#[cfg(feature = "auth")] +use cdk_common::AuthToken; +#[cfg(feature = "auth")] +use tokio::sync::RwLock; + +use crate::cdk_database::WalletDatabase; +use crate::error::Error; +use crate::mint_url::MintUrl; +use crate::nuts::CurrencyUnit; +#[cfg(feature = "auth")] +use crate::wallet::auth::AuthWallet; +use crate::wallet::{HttpClient, MintConnector, SubscriptionManager, Wallet}; + +/// Builder for creating a new [`Wallet`] +#[derive(Debug)] +pub struct WalletBuilder { + mint_url: Option, + unit: Option, + localstore: Option + Send + Sync>>, + target_proof_count: Option, + #[cfg(feature = "auth")] + auth_wallet: Option, + seed: Option>, + client: Option>, +} + +impl Default for WalletBuilder { + fn default() -> Self { + Self { + mint_url: None, + unit: None, + localstore: None, + target_proof_count: Some(3), + #[cfg(feature = "auth")] + auth_wallet: None, + seed: None, + client: None, + } + } +} + +impl WalletBuilder { + /// Create a new WalletBuilder + pub fn new() -> Self { + Self::default() + } + + /// Set the mint URL + pub fn mint_url(mut self, mint_url: MintUrl) -> Self { + self.mint_url = Some(mint_url); + self + } + + /// Set the currency unit + pub fn unit(mut self, unit: CurrencyUnit) -> Self { + self.unit = Some(unit); + self + } + + /// Set the local storage backend + pub fn localstore( + mut self, + localstore: Arc + Send + Sync>, + ) -> Self { + self.localstore = Some(localstore); + self + } + + /// Set the target proof count + pub fn target_proof_count(mut self, count: usize) -> Self { + self.target_proof_count = Some(count); + self + } + + /// Set the auth wallet + #[cfg(feature = "auth")] + pub fn auth_wallet(mut self, auth_wallet: AuthWallet) -> Self { + self.auth_wallet = Some(auth_wallet); + self + } + + /// Set the seed bytes + pub fn seed(mut self, seed: &[u8]) -> Self { + self.seed = Some(seed.to_vec()); + self + } + + /// Set a custom client connector + pub fn client(mut self, client: C) -> Self { + self.client = Some(Arc::new(client)); + self + } + + /// Set auth CAT (Clear Auth Token) + #[cfg(feature = "auth")] + pub fn set_auth_cat(mut self, cat: String) -> Self { + self.auth_wallet = Some(AuthWallet::new( + self.mint_url.clone().expect("Mint URL required"), + Some(AuthToken::ClearAuth(cat)), + self.localstore.clone().expect("Localstore required"), + HashMap::new(), + None, + )); + self + } + + /// Build the wallet + pub fn build(self) -> Result { + let mint_url = self + .mint_url + .ok_or(Error::Custom("Mint url required".to_string()))?; + let unit = self + .unit + .ok_or(Error::Custom("Unit required".to_string()))?; + let localstore = self + .localstore + .ok_or(Error::Custom("Localstore required".to_string()))?; + let seed = self + .seed + .as_ref() + .ok_or(Error::Custom("Seed required".to_string()))?; + + let xpriv = Xpriv::new_master(Network::Bitcoin, seed)?; + + let client = match self.client { + Some(client) => client, + None => { + #[cfg(feature = "auth")] + { + Arc::new(HttpClient::new(mint_url.clone(), self.auth_wallet.clone())) + as Arc + } + + #[cfg(not(feature = "auth"))] + { + Arc::new(HttpClient::new(mint_url.clone())) + as Arc + } + } + }; + + Ok(Wallet { + mint_url, + unit, + localstore, + target_proof_count: self.target_proof_count.unwrap_or(3), + #[cfg(feature = "auth")] + auth_wallet: Arc::new(RwLock::new(self.auth_wallet)), + xpriv, + client: client.clone(), + subscription: SubscriptionManager::new(client), + }) + } +} diff --git a/crates/cdk/src/wallet/client.rs b/crates/cdk/src/wallet/client.rs deleted file mode 100644 index 6059a45e..00000000 --- a/crates/cdk/src/wallet/client.rs +++ /dev/null @@ -1,308 +0,0 @@ -//! Wallet client - -use std::fmt::Debug; - -use async_trait::async_trait; -use reqwest::{Client, IntoUrl}; -use serde::de::DeserializeOwned; -use serde::Serialize; -use tracing::instrument; -#[cfg(not(target_arch = "wasm32"))] -use url::Url; - -use super::Error; -use crate::error::ErrorResponse; -use crate::mint_url::MintUrl; -use crate::nuts::{ - CheckStateRequest, CheckStateResponse, Id, KeySet, KeysResponse, KeysetResponse, - MeltBolt11Request, MeltQuoteBolt11Request, MeltQuoteBolt11Response, MintBolt11Request, - MintBolt11Response, MintInfo, MintQuoteBolt11Request, MintQuoteBolt11Response, RestoreRequest, - RestoreResponse, SwapRequest, SwapResponse, -}; - -/// Http Client -#[derive(Debug, Clone)] -pub struct HttpClient { - inner: Client, - mint_url: MintUrl, -} - -impl HttpClient { - /// Create new [`HttpClient`] - pub fn new(mint_url: MintUrl) -> Self { - Self { - inner: Client::new(), - mint_url, - } - } - - #[inline] - async fn http_get(&self, url: U) -> Result { - let response = self - .inner - .get(url) - .send() - .await - .map_err(|e| Error::HttpError(e.to_string()))? - .text() - .await - .map_err(|e| Error::HttpError(e.to_string()))?; - - serde_json::from_str::(&response).map_err(|err| { - tracing::warn!("Http Response error: {}", err); - match ErrorResponse::from_json(&response) { - Ok(ok) => >::into(ok), - Err(err) => err.into(), - } - }) - } - - #[inline] - async fn http_post( - &self, - url: U, - payload: &P, - ) -> Result { - let response = self - .inner - .post(url) - .json(&payload) - .send() - .await - .map_err(|e| Error::HttpError(e.to_string()))? - .text() - .await - .map_err(|e| Error::HttpError(e.to_string()))?; - - serde_json::from_str::(&response).map_err(|err| { - tracing::warn!("Http Response error: {}", err); - match ErrorResponse::from_json(&response) { - Ok(ok) => >::into(ok), - Err(err) => err.into(), - } - }) - } - - #[cfg(not(target_arch = "wasm32"))] - /// Create new [`HttpClient`] with a proxy for specific TLDs. - /// Specifying `None` for `host_matcher` will use the proxy for all - /// requests. - pub fn with_proxy( - mint_url: MintUrl, - proxy: Url, - host_matcher: Option<&str>, - accept_invalid_certs: bool, - ) -> Result { - let regex = host_matcher - .map(regex::Regex::new) - .transpose() - .map_err(|e| Error::Custom(e.to_string()))?; - let client = reqwest::Client::builder() - .proxy(reqwest::Proxy::custom(move |url| { - if let Some(matcher) = regex.as_ref() { - if let Some(host) = url.host_str() { - if matcher.is_match(host) { - return Some(proxy.clone()); - } - } - } - None - })) - .danger_accept_invalid_certs(accept_invalid_certs) // Allow self-signed certs - .build() - .map_err(|e| Error::HttpError(e.to_string()))?; - - Ok(Self { - inner: client, - mint_url, - }) - } -} - -#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] -#[cfg_attr(not(target_arch = "wasm32"), async_trait)] -impl MintConnector for HttpClient { - /// Get Active Mint Keys [NUT-01] - #[instrument(skip(self), fields(mint_url = %self.mint_url))] - async fn get_mint_keys(&self) -> Result, Error> { - let url = self.mint_url.join_paths(&["v1", "keys"])?; - Ok(self.http_get::<_, KeysResponse>(url).await?.keysets) - } - - /// Get Keyset Keys [NUT-01] - #[instrument(skip(self), fields(mint_url = %self.mint_url))] - async fn get_mint_keyset(&self, keyset_id: Id) -> Result { - let url = self - .mint_url - .join_paths(&["v1", "keys", &keyset_id.to_string()])?; - self.http_get::<_, KeysResponse>(url) - .await? - .keysets - .drain(0..1) - .next() - .ok_or_else(|| Error::UnknownKeySet) - } - - /// Get Keysets [NUT-02] - #[instrument(skip(self), fields(mint_url = %self.mint_url))] - async fn get_mint_keysets(&self) -> Result { - let url = self.mint_url.join_paths(&["v1", "keysets"])?; - self.http_get(url).await - } - - /// Mint Quote [NUT-04] - #[instrument(skip(self), fields(mint_url = %self.mint_url))] - async fn post_mint_quote( - &self, - request: MintQuoteBolt11Request, - ) -> Result, Error> { - let url = self - .mint_url - .join_paths(&["v1", "mint", "quote", "bolt11"])?; - self.http_post(url, &request).await - } - - /// Mint Quote status - #[instrument(skip(self), fields(mint_url = %self.mint_url))] - async fn get_mint_quote_status( - &self, - quote_id: &str, - ) -> Result, Error> { - let url = self - .mint_url - .join_paths(&["v1", "mint", "quote", "bolt11", quote_id])?; - - self.http_get(url).await - } - - /// Mint Tokens [NUT-04] - #[instrument(skip(self, request), fields(mint_url = %self.mint_url))] - async fn post_mint( - &self, - request: MintBolt11Request, - ) -> Result { - let url = self.mint_url.join_paths(&["v1", "mint", "bolt11"])?; - self.http_post(url, &request).await - } - - /// Melt Quote [NUT-05] - #[instrument(skip(self, request), fields(mint_url = %self.mint_url))] - async fn post_melt_quote( - &self, - request: MeltQuoteBolt11Request, - ) -> Result, Error> { - let url = self - .mint_url - .join_paths(&["v1", "melt", "quote", "bolt11"])?; - self.http_post(url, &request).await - } - - /// Melt Quote Status - #[instrument(skip(self), fields(mint_url = %self.mint_url))] - async fn get_melt_quote_status( - &self, - quote_id: &str, - ) -> Result, Error> { - let url = self - .mint_url - .join_paths(&["v1", "melt", "quote", "bolt11", quote_id])?; - - self.http_get(url).await - } - - /// Melt [NUT-05] - /// [Nut-08] Lightning fee return if outputs defined - #[instrument(skip(self, request), fields(mint_url = %self.mint_url))] - async fn post_melt( - &self, - request: MeltBolt11Request, - ) -> Result, Error> { - let url = self.mint_url.join_paths(&["v1", "melt", "bolt11"])?; - self.http_post(url, &request).await - } - - /// Swap Token [NUT-03] - #[instrument(skip(self, swap_request), fields(mint_url = %self.mint_url))] - async fn post_swap(&self, swap_request: SwapRequest) -> Result { - let url = self.mint_url.join_paths(&["v1", "swap"])?; - self.http_post(url, &swap_request).await - } - - /// Get Mint Info [NUT-06] - #[instrument(skip(self), fields(mint_url = %self.mint_url))] - async fn get_mint_info(&self) -> Result { - let url = self.mint_url.join_paths(&["v1", "info"])?; - self.http_get(url).await - } - - /// Spendable check [NUT-07] - #[instrument(skip(self, request), fields(mint_url = %self.mint_url))] - async fn post_check_state( - &self, - request: CheckStateRequest, - ) -> Result { - let url = self.mint_url.join_paths(&["v1", "checkstate"])?; - self.http_post(url, &request).await - } - - /// Restore request [NUT-13] - #[instrument(skip(self, request), fields(mint_url = %self.mint_url))] - async fn post_restore(&self, request: RestoreRequest) -> Result { - let url = self.mint_url.join_paths(&["v1", "restore"])?; - self.http_post(url, &request).await - } -} - -/// Interface that connects a wallet to a mint. Typically represents an [HttpClient]. -#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] -#[cfg_attr(not(target_arch = "wasm32"), async_trait)] -pub trait MintConnector: Debug { - /// Get Active Mint Keys [NUT-01] - async fn get_mint_keys(&self) -> Result, Error>; - /// Get Keyset Keys [NUT-01] - async fn get_mint_keyset(&self, keyset_id: Id) -> Result; - /// Get Keysets [NUT-02] - async fn get_mint_keysets(&self) -> Result; - /// Mint Quote [NUT-04] - async fn post_mint_quote( - &self, - request: MintQuoteBolt11Request, - ) -> Result, Error>; - /// Mint Quote status - async fn get_mint_quote_status( - &self, - quote_id: &str, - ) -> Result, Error>; - /// Mint Tokens [NUT-04] - async fn post_mint( - &self, - request: MintBolt11Request, - ) -> Result; - /// Melt Quote [NUT-05] - async fn post_melt_quote( - &self, - request: MeltQuoteBolt11Request, - ) -> Result, Error>; - /// Melt Quote Status - async fn get_melt_quote_status( - &self, - quote_id: &str, - ) -> Result, Error>; - /// Melt [NUT-05] - /// [Nut-08] Lightning fee return if outputs defined - async fn post_melt( - &self, - request: MeltBolt11Request, - ) -> Result, Error>; - /// Split Token [NUT-06] - async fn post_swap(&self, request: SwapRequest) -> Result; - /// Get Mint Info [NUT-06] - async fn get_mint_info(&self) -> Result; - /// Spendable check [NUT-07] - async fn post_check_state( - &self, - request: CheckStateRequest, - ) -> Result; - /// Restore request [NUT-13] - async fn post_restore(&self, request: RestoreRequest) -> Result; -} diff --git a/crates/cdk/src/wallet/mint_connector/http_client.rs b/crates/cdk/src/wallet/mint_connector/http_client.rs new file mode 100644 index 00000000..b7a4f199 --- /dev/null +++ b/crates/cdk/src/wallet/mint_connector/http_client.rs @@ -0,0 +1,480 @@ +#[cfg(feature = "auth")] +use std::sync::Arc; + +use async_trait::async_trait; +#[cfg(feature = "auth")] +use cdk_common::{Method, ProtectedEndpoint, RoutePath}; +use reqwest::{Client, IntoUrl}; +use serde::de::DeserializeOwned; +use serde::Serialize; +#[cfg(feature = "auth")] +use tokio::sync::RwLock; +use tracing::instrument; +#[cfg(not(target_arch = "wasm32"))] +use url::Url; + +use super::{Error, MintConnector}; +use crate::error::ErrorResponse; +use crate::mint_url::MintUrl; +#[cfg(feature = "auth")] +use crate::nuts::nut22::MintAuthRequest; +use crate::nuts::{ + AuthToken, CheckStateRequest, CheckStateResponse, Id, KeySet, KeysResponse, KeysetResponse, + MeltBolt11Request, MeltQuoteBolt11Request, MeltQuoteBolt11Response, MintBolt11Request, + MintBolt11Response, MintInfo, MintQuoteBolt11Request, MintQuoteBolt11Response, RestoreRequest, + RestoreResponse, SwapRequest, SwapResponse, +}; +#[cfg(feature = "auth")] +use crate::wallet::auth::{AuthMintConnector, AuthWallet}; + +#[derive(Debug, Clone)] +struct HttpClientCore { + inner: Client, +} + +impl HttpClientCore { + fn new() -> Self { + Self { + inner: Client::new(), + } + } + + fn client(&self) -> &Client { + &self.inner + } + + async fn http_get( + &self, + url: U, + auth: Option, + ) -> Result { + let mut request = self.client().get(url); + + if let Some(auth) = auth { + request = request.header(auth.header_key(), auth.to_string()); + } + + let response = request + .send() + .await + .map_err(|e| Error::HttpError(e.to_string()))? + .text() + .await + .map_err(|e| Error::HttpError(e.to_string()))?; + + serde_json::from_str::(&response).map_err(|err| { + tracing::warn!("Http Response error: {}", err); + match ErrorResponse::from_json(&response) { + Ok(ok) => >::into(ok), + Err(err) => err.into(), + } + }) + } + + async fn http_post( + &self, + url: U, + auth_token: Option, + payload: &P, + ) -> Result { + let mut request = self.client().post(url).json(&payload); + + if let Some(auth) = auth_token { + request = request.header(auth.header_key(), auth.to_string()); + } + + let response = request + .send() + .await + .map_err(|e| Error::HttpError(e.to_string()))? + .text() + .await + .map_err(|e| Error::HttpError(e.to_string()))?; + + serde_json::from_str::(&response).map_err(|err| { + tracing::warn!("Http Response error: {}", err); + match ErrorResponse::from_json(&response) { + Ok(ok) => >::into(ok), + Err(err) => err.into(), + } + }) + } +} + +/// Http Client +#[derive(Debug, Clone)] +pub struct HttpClient { + core: HttpClientCore, + mint_url: MintUrl, + #[cfg(feature = "auth")] + auth_wallet: Arc>>, +} + +impl HttpClient { + /// Create new [`HttpClient`] + #[cfg(feature = "auth")] + pub fn new(mint_url: MintUrl, auth_wallet: Option) -> Self { + Self { + core: HttpClientCore::new(), + mint_url, + auth_wallet: Arc::new(RwLock::new(auth_wallet)), + } + } + + #[cfg(not(feature = "auth"))] + /// Create new [`HttpClient`] + pub fn new(mint_url: MintUrl) -> Self { + Self { + core: HttpClientCore::new(), + mint_url, + } + } + + /// Get auth token for a protected endpoint + #[cfg(feature = "auth")] + async fn get_auth_token( + &self, + method: Method, + path: RoutePath, + ) -> Result, Error> { + let auth_wallet = self.auth_wallet.read().await; + match auth_wallet.as_ref() { + Some(auth_wallet) => { + let endpoint = ProtectedEndpoint::new(method, path); + auth_wallet.get_auth_for_request(&endpoint).await + } + None => Ok(None), + } + } + + #[cfg(not(target_arch = "wasm32"))] + /// Create new [`HttpClient`] with a proxy for specific TLDs. + /// Specifying `None` for `host_matcher` will use the proxy for all + /// requests. + pub fn with_proxy( + mint_url: MintUrl, + proxy: Url, + host_matcher: Option<&str>, + accept_invalid_certs: bool, + ) -> Result { + let regex = host_matcher + .map(regex::Regex::new) + .transpose() + .map_err(|e| Error::Custom(e.to_string()))?; + let client = reqwest::Client::builder() + .proxy(reqwest::Proxy::custom(move |url| { + if let Some(matcher) = regex.as_ref() { + if let Some(host) = url.host_str() { + if matcher.is_match(host) { + return Some(proxy.clone()); + } + } + } + None + })) + .danger_accept_invalid_certs(accept_invalid_certs) // Allow self-signed certs + .build() + .map_err(|e| Error::HttpError(e.to_string()))?; + + Ok(Self { + core: HttpClientCore { inner: client }, + mint_url, + #[cfg(feature = "auth")] + auth_wallet: Arc::new(RwLock::new(None)), + }) + } +} + +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] +impl MintConnector for HttpClient { + /// Get Active Mint Keys [NUT-01] + #[instrument(skip(self), fields(mint_url = %self.mint_url))] + async fn get_mint_keys(&self) -> Result, Error> { + let url = self.mint_url.join_paths(&["v1", "keys"])?; + + Ok(self + .core + .http_get::<_, KeysResponse>(url, None) + .await? + .keysets) + } + + /// Get Keyset Keys [NUT-01] + #[instrument(skip(self), fields(mint_url = %self.mint_url))] + async fn get_mint_keyset(&self, keyset_id: Id) -> Result { + let url = self + .mint_url + .join_paths(&["v1", "keys", &keyset_id.to_string()])?; + + let keys_response = self.core.http_get::<_, KeysResponse>(url, None).await?; + + Ok(keys_response.keysets.first().unwrap().clone()) + } + + /// Get Keysets [NUT-02] + #[instrument(skip(self), fields(mint_url = %self.mint_url))] + async fn get_mint_keysets(&self) -> Result { + let url = self.mint_url.join_paths(&["v1", "keysets"])?; + self.core.http_get(url, None).await + } + + /// Mint Quote [NUT-04] + #[instrument(skip(self), fields(mint_url = %self.mint_url))] + async fn post_mint_quote( + &self, + request: MintQuoteBolt11Request, + ) -> Result, Error> { + let url = self + .mint_url + .join_paths(&["v1", "mint", "quote", "bolt11"])?; + + #[cfg(feature = "auth")] + let auth_token = self + .get_auth_token(Method::Post, RoutePath::MintQuoteBolt11) + .await?; + + #[cfg(not(feature = "auth"))] + let auth_token = None; + + self.core.http_post(url, auth_token, &request).await + } + + /// Mint Quote status + #[instrument(skip(self), fields(mint_url = %self.mint_url))] + async fn get_mint_quote_status( + &self, + quote_id: &str, + ) -> Result, Error> { + let url = self + .mint_url + .join_paths(&["v1", "mint", "quote", "bolt11", quote_id])?; + + #[cfg(feature = "auth")] + let auth_token = self + .get_auth_token(Method::Get, RoutePath::MintQuoteBolt11) + .await?; + + #[cfg(not(feature = "auth"))] + let auth_token = None; + self.core.http_get(url, auth_token).await + } + + /// Mint Tokens [NUT-04] + #[instrument(skip(self, request), fields(mint_url = %self.mint_url))] + async fn post_mint( + &self, + request: MintBolt11Request, + ) -> Result { + let url = self.mint_url.join_paths(&["v1", "mint", "bolt11"])?; + #[cfg(feature = "auth")] + let auth_token = self + .get_auth_token(Method::Post, RoutePath::MintBolt11) + .await?; + + #[cfg(not(feature = "auth"))] + let auth_token = None; + self.core.http_post(url, auth_token, &request).await + } + + /// Melt Quote [NUT-05] + #[instrument(skip(self, request), fields(mint_url = %self.mint_url))] + async fn post_melt_quote( + &self, + request: MeltQuoteBolt11Request, + ) -> Result, Error> { + let url = self + .mint_url + .join_paths(&["v1", "melt", "quote", "bolt11"])?; + #[cfg(feature = "auth")] + let auth_token = self + .get_auth_token(Method::Post, RoutePath::MeltQuoteBolt11) + .await?; + + #[cfg(not(feature = "auth"))] + let auth_token = None; + self.core.http_post(url, auth_token, &request).await + } + + /// Melt Quote Status + #[instrument(skip(self), fields(mint_url = %self.mint_url))] + async fn get_melt_quote_status( + &self, + quote_id: &str, + ) -> Result, Error> { + let url = self + .mint_url + .join_paths(&["v1", "melt", "quote", "bolt11", quote_id])?; + + #[cfg(feature = "auth")] + let auth_token = self + .get_auth_token(Method::Get, RoutePath::MeltQuoteBolt11) + .await?; + + #[cfg(not(feature = "auth"))] + let auth_token = None; + self.core.http_get(url, auth_token).await + } + + /// Melt [NUT-05] + /// [Nut-08] Lightning fee return if outputs defined + #[instrument(skip(self, request), fields(mint_url = %self.mint_url))] + async fn post_melt( + &self, + request: MeltBolt11Request, + ) -> Result, Error> { + let url = self.mint_url.join_paths(&["v1", "melt", "bolt11"])?; + #[cfg(feature = "auth")] + let auth_token = self + .get_auth_token(Method::Post, RoutePath::MeltBolt11) + .await?; + + #[cfg(not(feature = "auth"))] + let auth_token = None; + self.core.http_post(url, auth_token, &request).await + } + + /// Swap Token [NUT-03] + #[instrument(skip(self, swap_request), fields(mint_url = %self.mint_url))] + async fn post_swap(&self, swap_request: SwapRequest) -> Result { + let url = self.mint_url.join_paths(&["v1", "swap"])?; + #[cfg(feature = "auth")] + let auth_token = self.get_auth_token(Method::Post, RoutePath::Swap).await?; + + #[cfg(not(feature = "auth"))] + let auth_token = None; + self.core.http_post(url, auth_token, &swap_request).await + } + + /// Helper to get mint info + async fn get_mint_info(&self) -> Result { + let url = self.mint_url.join_paths(&["v1", "info"])?; + self.core.http_get(url, None).await + } + + #[cfg(feature = "auth")] + async fn get_auth_wallet(&self) -> Option { + self.auth_wallet.read().await.clone() + } + + #[cfg(feature = "auth")] + async fn set_auth_wallet(&self, wallet: Option) { + *self.auth_wallet.write().await = wallet; + } + + /// Spendable check [NUT-07] + #[instrument(skip(self, request), fields(mint_url = %self.mint_url))] + async fn post_check_state( + &self, + request: CheckStateRequest, + ) -> Result { + let url = self.mint_url.join_paths(&["v1", "checkstate"])?; + #[cfg(feature = "auth")] + let auth_token = self + .get_auth_token(Method::Post, RoutePath::Checkstate) + .await?; + + #[cfg(not(feature = "auth"))] + let auth_token = None; + self.core.http_post(url, auth_token, &request).await + } + + /// Restore request [NUT-13] + #[instrument(skip(self, request), fields(mint_url = %self.mint_url))] + async fn post_restore(&self, request: RestoreRequest) -> Result { + let url = self.mint_url.join_paths(&["v1", "restore"])?; + #[cfg(feature = "auth")] + let auth_token = self + .get_auth_token(Method::Post, RoutePath::Restore) + .await?; + + #[cfg(not(feature = "auth"))] + let auth_token = None; + self.core.http_post(url, auth_token, &request).await + } +} + +/// Http Client +#[derive(Debug, Clone)] +#[cfg(feature = "auth")] +pub struct AuthHttpClient { + core: HttpClientCore, + mint_url: MintUrl, + cat: Arc>, +} + +#[cfg(feature = "auth")] +impl AuthHttpClient { + /// Create new [`AuthHttpClient`] + pub fn new(mint_url: MintUrl, cat: Option) -> Self { + Self { + core: HttpClientCore::new(), + mint_url, + cat: Arc::new(RwLock::new( + cat.unwrap_or(AuthToken::ClearAuth("".to_string())), + )), + } + } +} + +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] +#[cfg(feature = "auth")] +impl AuthMintConnector for AuthHttpClient { + async fn get_auth_token(&self) -> Result { + Ok(self.cat.read().await.clone()) + } + + async fn set_auth_token(&self, token: AuthToken) -> Result<(), Error> { + *self.cat.write().await = token; + Ok(()) + } + + /// Get Mint Info [NUT-06] + async fn get_mint_info(&self) -> Result { + let url = self.mint_url.join_paths(&["v1", "info"])?; + let mint_info: MintInfo = self.core.http_get::<_, MintInfo>(url, None).await?; + + Ok(mint_info) + } + + /// Get Auth Keyset Keys [NUT-22] + #[instrument(skip(self), fields(mint_url = %self.mint_url))] + async fn get_mint_blind_auth_keyset(&self, keyset_id: Id) -> Result { + let url = + self.mint_url + .join_paths(&["v1", "auth", "blind", "keys", &keyset_id.to_string()])?; + + let mut keys_response = self.core.http_get::<_, KeysResponse>(url, None).await?; + + let keyset = keys_response + .keysets + .drain(0..1) + .next() + .ok_or_else(|| Error::UnknownKeySet)?; + + Ok(keyset) + } + + /// Get Auth Keysets [NUT-22] + #[instrument(skip(self), fields(mint_url = %self.mint_url))] + async fn get_mint_blind_auth_keysets(&self) -> Result { + let url = self + .mint_url + .join_paths(&["v1", "auth", "blind", "keysets"])?; + + self.core.http_get(url, None).await + } + + /// Mint Tokens [NUT-22] + #[instrument(skip(self, request), fields(mint_url = %self.mint_url))] + async fn post_mint_blind_auth( + &self, + request: MintAuthRequest, + ) -> Result { + let url = self.mint_url.join_paths(&["v1", "auth", "blind", "mint"])?; + self.core + .http_post(url, Some(self.cat.read().await.clone()), &request) + .await + } +} diff --git a/crates/cdk/src/wallet/mint_connector/mod.rs b/crates/cdk/src/wallet/mint_connector/mod.rs new file mode 100644 index 00000000..bbf9a719 --- /dev/null +++ b/crates/cdk/src/wallet/mint_connector/mod.rs @@ -0,0 +1,83 @@ +//! Wallet client + +use std::fmt::Debug; + +use async_trait::async_trait; + +use super::Error; +use crate::nuts::{ + CheckStateRequest, CheckStateResponse, Id, KeySet, KeysetResponse, MeltBolt11Request, + MeltQuoteBolt11Request, MeltQuoteBolt11Response, MintBolt11Request, MintBolt11Response, + MintInfo, MintQuoteBolt11Request, MintQuoteBolt11Response, RestoreRequest, RestoreResponse, + SwapRequest, SwapResponse, +}; +#[cfg(feature = "auth")] +use crate::wallet::AuthWallet; + +mod http_client; + +#[cfg(feature = "auth")] +pub use http_client::AuthHttpClient; +pub use http_client::HttpClient; + +/// Interface that connects a wallet to a mint. Typically represents an [HttpClient]. +#[cfg_attr(target_arch = "wasm32", async_trait(?Send))] +#[cfg_attr(not(target_arch = "wasm32"), async_trait)] +pub trait MintConnector: Debug { + /// Get Active Mint Keys [NUT-01] + async fn get_mint_keys(&self) -> Result, Error>; + /// Get Keyset Keys [NUT-01] + async fn get_mint_keyset(&self, keyset_id: Id) -> Result; + /// Get Keysets [NUT-02] + async fn get_mint_keysets(&self) -> Result; + /// Mint Quote [NUT-04] + async fn post_mint_quote( + &self, + request: MintQuoteBolt11Request, + ) -> Result, Error>; + /// Mint Quote status + async fn get_mint_quote_status( + &self, + quote_id: &str, + ) -> Result, Error>; + /// Mint Tokens [NUT-04] + async fn post_mint( + &self, + request: MintBolt11Request, + ) -> Result; + /// Melt Quote [NUT-05] + async fn post_melt_quote( + &self, + request: MeltQuoteBolt11Request, + ) -> Result, Error>; + /// Melt Quote Status + async fn get_melt_quote_status( + &self, + quote_id: &str, + ) -> Result, Error>; + /// Melt [NUT-05] + /// [Nut-08] Lightning fee return if outputs defined + async fn post_melt( + &self, + request: MeltBolt11Request, + ) -> Result, Error>; + /// Split Token [NUT-06] + async fn post_swap(&self, request: SwapRequest) -> Result; + /// Get Mint Info [NUT-06] + async fn get_mint_info(&self) -> Result; + /// Spendable check [NUT-07] + async fn post_check_state( + &self, + request: CheckStateRequest, + ) -> Result; + /// Restore request [NUT-13] + async fn post_restore(&self, request: RestoreRequest) -> Result; + + /// Get the auth wallet for the client + #[cfg(feature = "auth")] + async fn get_auth_wallet(&self) -> Option; + + /// Set auth wallet on client + #[cfg(feature = "auth")] + async fn set_auth_wallet(&self, wallet: Option); +} diff --git a/crates/cdk/src/wallet/mod.rs b/crates/cdk/src/wallet/mod.rs index fe0019be..74c998ec 100644 --- a/crates/cdk/src/wallet/mod.rs +++ b/crates/cdk/src/wallet/mod.rs @@ -5,15 +5,13 @@ use std::str::FromStr; use std::sync::Arc; use bitcoin::bip32::Xpriv; -use bitcoin::Network; use cdk_common::database::{self, WalletDatabase}; use cdk_common::subscription::Params; -use client::MintConnector; use getrandom::getrandom; -pub use multi_mint_wallet::MultiMintWallet; use subscription::{ActiveSubscription, SubscriptionManager}; +#[cfg(feature = "auth")] +use tokio::sync::RwLock; use tracing::instrument; -pub use types::{MeltQuote, MintQuote, SendKind}; use crate::amount::SplitTarget; use crate::dhke::construct_proofs; @@ -27,13 +25,19 @@ use crate::nuts::{ RestoreRequest, SpendingConditions, State, }; use crate::types::ProofInfo; -use crate::{Amount, HttpClient}; +use crate::util::unix_time; +use crate::Amount; +#[cfg(feature = "auth")] +use crate::OidcClient; +#[cfg(feature = "auth")] +mod auth; mod balance; -pub mod client; +mod builder; mod keysets; mod melt; mod mint; +mod mint_connector; pub mod multi_mint_wallet; mod proofs; mod receive; @@ -42,8 +46,16 @@ pub mod subscription; mod swap; pub mod util; +#[cfg(feature = "auth")] +pub use auth::{AuthMintConnector, AuthWallet}; +pub use builder::WalletBuilder; pub use cdk_common::wallet as types; +#[cfg(feature = "auth")] +pub use mint_connector::AuthHttpClient; +pub use mint_connector::{HttpClient, MintConnector}; +pub use multi_mint_wallet::MultiMintWallet; pub use send::{PreparedSend, SendMemo, SendOptions}; +pub use types::{MeltQuote, MintQuote, SendKind}; use crate::nuts::nut00::ProofsMethods; @@ -62,6 +74,8 @@ pub struct Wallet { pub localstore: Arc + Send + Sync>, /// The targeted amount of proofs to have at each size pub target_proof_count: usize, + #[cfg(feature = "auth")] + auth_wallet: Arc>>, xpriv: Xpriv, client: Arc, subscription: SubscriptionManager, @@ -115,14 +129,16 @@ impl From for Params { } impl Wallet { - /// Create new [`Wallet`] + /// Create new [`Wallet`] using the builder pattern /// # Synopsis /// ```rust /// use std::sync::Arc; + /// use bitcoin::Network; + /// use bitcoin::bip32::Xpriv; /// /// use cdk_sqlite::wallet::memory; /// use cdk::nuts::CurrencyUnit; - /// use cdk::wallet::Wallet; + /// use cdk::wallet::{Wallet, WalletBuilder}; /// use rand::Rng; /// /// async fn test() -> anyhow::Result<()> { @@ -131,7 +147,12 @@ impl Wallet { /// let unit = CurrencyUnit::Sat; /// /// let localstore = memory::empty().await?; - /// let wallet = Wallet::new(mint_url, unit, Arc::new(localstore), &seed, None); + /// let wallet = WalletBuilder::new() + /// .mint_url(mint_url.parse().unwrap()) + /// .unit(unit) + /// .localstore(Arc::new(localstore)) + /// .seed(&seed) + /// .build(); /// Ok(()) /// } /// ``` @@ -142,32 +163,21 @@ impl Wallet { seed: &[u8], target_proof_count: Option, ) -> Result { - let xpriv = Xpriv::new_master(Network::Bitcoin, seed).expect("Could not create master key"); let mint_url = MintUrl::from_str(mint_url)?; - let http_client = Arc::new(HttpClient::new(mint_url.clone())); - - Ok(Self { - mint_url: mint_url.clone(), - unit, - client: http_client.clone(), - subscription: SubscriptionManager::new(http_client), - localstore, - xpriv, - target_proof_count: target_proof_count.unwrap_or(3), - }) - } - - /// Change HTTP client - pub fn set_client(&mut self, client: C) { - self.client = Arc::new(client); - self.subscription = SubscriptionManager::new(self.client.clone()); + WalletBuilder::new() + .mint_url(mint_url) + .unit(unit) + .localstore(localstore) + .seed(seed) + .target_proof_count(target_proof_count.unwrap_or(3)) + .build() } /// Subscribe to events pub async fn subscribe>(&self, query: T) -> ActiveSubscription { self.subscription - .subscribe(self.mint_url.clone(), query.into()) + .subscribe(self.mint_url.clone(), query.into(), Arc::new(self.clone())) .await } @@ -232,21 +242,68 @@ impl Wallet { /// Query mint for current mint information #[instrument(skip(self))] pub async fn get_mint_info(&self) -> Result, Error> { - let mint_info = match self.client.get_mint_info().await { - Ok(mint_info) => Some(mint_info), + match self.client.get_mint_info().await { + Ok(mint_info) => { + // If mint provides time make sure it is accurate + if let Some(mint_unix_time) = mint_info.time { + let current_unix_time = unix_time(); + if current_unix_time.abs_diff(mint_unix_time) > 30 { + tracing::warn!( + "Mint time does match wallet time. Mint: {}, Wallet: {}", + mint_unix_time, + current_unix_time + ); + return Err(Error::MintTimeExceedsTolerance); + } + } + + // Create or update auth wallet + #[cfg(feature = "auth")] + { + let mut auth_wallet = self.auth_wallet.write().await; + match &*auth_wallet { + Some(auth_wallet) => { + let mut protected_endpoints = + auth_wallet.protected_endpoints.write().await; + *protected_endpoints = mint_info.protected_endpoints(); + + if let Some(oidc_client) = + mint_info.openid_discovery().map(OidcClient::new) + { + auth_wallet.set_oidc_client(Some(oidc_client)).await; + } + } + None => { + tracing::info!("Mint has auth enabled creating auth wallet"); + + let oidc_client = mint_info.openid_discovery().map(OidcClient::new); + let new_auth_wallet = AuthWallet::new( + self.mint_url.clone(), + None, + self.localstore.clone(), + mint_info.protected_endpoints(), + oidc_client, + ); + *auth_wallet = Some(new_auth_wallet.clone()); + + self.client.set_auth_wallet(Some(new_auth_wallet)).await; + } + } + } + + self.localstore + .add_mint(self.mint_url.clone(), Some(mint_info.clone())) + .await?; + + tracing::trace!("Mint info updated for {}", self.mint_url); + + Ok(Some(mint_info)) + } Err(err) => { tracing::warn!("Could not get mint info {}", err); - None + Ok(None) } - }; - - self.localstore - .add_mint(self.mint_url.clone(), mint_info.clone()) - .await?; - - tracing::trace!("Mint info updated for {}", self.mint_url); - - Ok(mint_info) + } } /// Get amounts needed to refill proof state diff --git a/crates/cdk/src/wallet/proofs.rs b/crates/cdk/src/wallet/proofs.rs index f0cd6411..8b5b3d6d 100644 --- a/crates/cdk/src/wallet/proofs.rs +++ b/crates/cdk/src/wallet/proofs.rs @@ -100,6 +100,7 @@ impl Wallet { .client .post_check_state(CheckStateRequest { ys: proofs.ys()? }) .await?; + let spent_ys: Vec<_> = spendable .states .iter() diff --git a/crates/cdk/src/wallet/subscription/http.rs b/crates/cdk/src/wallet/subscription/http.rs index a692d627..a14304ef 100644 --- a/crates/cdk/src/wallet/subscription/http.rs +++ b/crates/cdk/src/wallet/subscription/http.rs @@ -9,7 +9,8 @@ use super::WsSubscriptionBody; use crate::nuts::nut17::Kind; use crate::nuts::{nut01, nut04, nut05, nut07, CheckStateRequest, NotificationPayload}; use crate::pub_sub::SubId; -use crate::wallet::client::MintConnector; +use crate::wallet::MintConnector; +use crate::Wallet; #[derive(Debug, Hash, PartialEq, Eq)] enum UrlType { @@ -77,6 +78,7 @@ pub async fn http_main>( subscriptions: Arc>>, mut new_subscription_recv: mpsc::Receiver, mut on_drop: mpsc::Receiver, + _wallet: Arc, ) { let mut interval = time::interval(Duration::from_secs(2)); let mut subscribed_to = HashMap::, _, AnyState)>::new(); @@ -92,6 +94,7 @@ pub async fn http_main>( tracing::debug!("Polling: {:?}", url); match url { UrlType::Mint(id) => { + let response = http_client.get_mint_quote_status(id).await; if let Ok(response) = response { if *last_state == AnyState::MintQuoteState(response.state) { @@ -104,6 +107,7 @@ pub async fn http_main>( } } UrlType::Melt(id) => { + let response = http_client.get_melt_quote_status(id).await; if let Ok(response) = response { if *last_state == AnyState::MeltQuoteState(response.state) { @@ -118,7 +122,8 @@ pub async fn http_main>( UrlType::PublicKey(id) => { let responses = http_client.post_check_state(CheckStateRequest { ys: vec![*id], - }).await; + } + ).await; if let Ok(mut responses) = responses { let response = if let Some(state) = responses.states.pop() { state diff --git a/crates/cdk/src/wallet/subscription/mod.rs b/crates/cdk/src/wallet/subscription/mod.rs index 65c96860..dccfc1c0 100644 --- a/crates/cdk/src/wallet/subscription/mod.rs +++ b/crates/cdk/src/wallet/subscription/mod.rs @@ -14,9 +14,10 @@ use tokio::sync::{mpsc, RwLock}; use tokio::task::JoinHandle; use tracing::error; +use super::Wallet; use crate::mint_url::MintUrl; use crate::pub_sub::SubId; -use crate::wallet::client::MintConnector; +use crate::wallet::MintConnector; mod http; #[cfg(all( @@ -59,7 +60,12 @@ impl SubscriptionManager { } /// Subscribe to updates from a mint server with a given filter - pub async fn subscribe(&self, mint_url: MintUrl, filter: Params) -> ActiveSubscription { + pub async fn subscribe( + &self, + mint_url: MintUrl, + filter: Params, + wallet: Arc, + ) -> ActiveSubscription { let subscription_clients = self.all_connections.read().await; let id = filter.id.clone(); if let Some(subscription_client) = subscription_clients.get(&mint_url) { @@ -94,8 +100,12 @@ impl SubscriptionManager { ); let mut subscription_clients = self.all_connections.write().await; - let subscription_client = - SubscriptionClient::new(mint_url.clone(), self.http_client.clone(), is_ws_support); + let subscription_client = SubscriptionClient::new( + mint_url.clone(), + self.http_client.clone(), + is_ws_support, + wallet, + ); let (on_drop_notif, receiver) = subscription_client.subscribe(filter).await; subscription_clients.insert(mint_url, subscription_client); @@ -179,6 +189,7 @@ impl SubscriptionClient { url: MintUrl, http_client: Arc, prefer_ws_method: bool, + wallet: Arc, ) -> Self { let subscriptions = Arc::new(RwLock::new(HashMap::new())); let (new_subscription_notif, new_subscription_recv) = mpsc::channel(100); @@ -195,6 +206,7 @@ impl SubscriptionClient { subscriptions, new_subscription_recv, on_drop_recv, + wallet, )), } } @@ -207,6 +219,7 @@ impl SubscriptionClient { subscriptions: Arc>>, new_subscription_recv: mpsc::Receiver, on_drop_recv: mpsc::Receiver, + wallet: Arc, ) -> JoinHandle<()> { #[cfg(any( feature = "http_subscription", @@ -218,6 +231,7 @@ impl SubscriptionClient { subscriptions, new_subscription_recv, on_drop_recv, + wallet, ); #[cfg(all( @@ -232,6 +246,7 @@ impl SubscriptionClient { subscriptions, new_subscription_recv, on_drop_recv, + wallet, ) } else { Self::http_worker( @@ -239,6 +254,7 @@ impl SubscriptionClient { subscriptions, new_subscription_recv, on_drop_recv, + wallet, ) } } @@ -268,6 +284,7 @@ impl SubscriptionClient { subscriptions: Arc>>, new_subscription_recv: mpsc::Receiver, on_drop: mpsc::Receiver, + wallet: Arc, ) -> JoinHandle<()> { let http_worker = http::http_main( vec![], @@ -275,6 +292,7 @@ impl SubscriptionClient { subscriptions, new_subscription_recv, on_drop, + wallet, ); #[cfg(target_arch = "wasm32")] @@ -301,6 +319,7 @@ impl SubscriptionClient { subscriptions: Arc>>, new_subscription_recv: mpsc::Receiver, on_drop: mpsc::Receiver, + wallet: Arc, ) -> JoinHandle<()> { tokio::spawn(ws::ws_main( http_client, @@ -308,6 +327,7 @@ impl SubscriptionClient { subscriptions, new_subscription_recv, on_drop, + wallet, )) } } diff --git a/crates/cdk/src/wallet/subscription/ws.rs b/crates/cdk/src/wallet/subscription/ws.rs index 6e5fd450..2b5a7d06 100644 --- a/crates/cdk/src/wallet/subscription/ws.rs +++ b/crates/cdk/src/wallet/subscription/ws.rs @@ -13,7 +13,8 @@ use super::http::http_main; use super::WsSubscriptionBody; use crate::mint_url::MintUrl; use crate::pub_sub::SubId; -use crate::wallet::client::MintConnector; +use crate::wallet::MintConnector; +use crate::Wallet; const MAX_ATTEMPT_FALLBACK_HTTP: usize = 10; @@ -23,6 +24,7 @@ async fn fallback_to_http>( subscriptions: Arc>>, new_subscription_recv: mpsc::Receiver, on_drop: mpsc::Receiver, + wallet: Arc, ) { http_main( initial_state, @@ -30,6 +32,7 @@ async fn fallback_to_http>( subscriptions, new_subscription_recv, on_drop, + wallet, ) .await } @@ -41,6 +44,7 @@ pub async fn ws_main( subscriptions: Arc>>, mut new_subscription_recv: mpsc::Receiver, mut on_drop: mpsc::Receiver, + wallet: Arc, ) { let url = mint_url .join_paths(&["v1", "ws"]) @@ -76,6 +80,7 @@ pub async fn ws_main( subscriptions, new_subscription_recv, on_drop, + wallet, ) .await; } @@ -175,6 +180,7 @@ pub async fn ws_main( subscriptions, new_subscription_recv, on_drop, + wallet ).await; } } diff --git a/flake.nix b/flake.nix index 6309977a..534739c1 100644 --- a/flake.nix +++ b/flake.nix @@ -229,6 +229,9 @@ ${_shellHook} cargo update cargo update -p async-compression --precise 0.4.3 + cargo update -p zstd-sys --precise 2.0.8+zstd.1.5.5 + cargo update -p flate2 --precise 1.0.35 + cargo update -p home --precise 0.5.5 cargo update -p zerofrom --precise 0.1.5 diff --git a/justfile b/justfile index 1a0ae7e3..b191ec5e 100644 --- a/justfile +++ b/justfile @@ -35,7 +35,7 @@ format: cargo fmt --all nixpkgs-fmt $(echo **.nix) -# run tests +# run doc tests test: build #!/usr/bin/env bash set -euo pipefail @@ -48,6 +48,13 @@ test: build cargo test -p cdk-integration-tests --test integration_tests_pure cargo test -p cdk-integration-tests --test mint +test-all db: + #!/usr/bin/env bash + just test + ./misc/itests.sh "{{db}}" + ./misc/fake_itests.sh "{{db}}" + + # run `cargo clippy` on everything clippy *ARGS="--locked --offline --workspace --all-targets": cargo clippy {{ARGS}} @@ -78,6 +85,11 @@ itest-payment-processor ln: #!/usr/bin/env bash ./misc/mintd_payment_processor.sh "{{ln}}" + +fake-auth-mint-itest db openid_discovery: + #!/usr/bin/env bash + ./misc/fake_auth_itests.sh "{{db}}" "{{openid_discovery}}" + run-examples: cargo r --example p2pk cargo r --example mint-token diff --git a/misc/fake_auth_itests.sh b/misc/fake_auth_itests.sh new file mode 100755 index 00000000..542dd8a1 --- /dev/null +++ b/misc/fake_auth_itests.sh @@ -0,0 +1,109 @@ + +#!/usr/bin/env bash + +# Function to perform cleanup +cleanup() { + echo "Cleaning up..." + + echo "Killing the cdk mintd" + kill -2 $cdk_mintd_pid + wait $cdk_mintd_pid + + echo "Mint binary terminated" + + # Remove the temporary directory + rm -rf "$cdk_itests" + echo "Temp directory removed: $cdk_itests" + unset cdk_itests + unset cdk_itests_mint_addr + unset cdk_itests_mint_port +} + +# Set up trap to call cleanup on script exit +trap cleanup EXIT + +# Create a temporary directory +export cdk_itests=$(mktemp -d) +export cdk_itests_mint_addr="127.0.0.1"; +export cdk_itests_mint_port=8087; + +# Check if the temporary directory was created successfully +if [[ ! -d "$cdk_itests" ]]; then + echo "Failed to create temp directory" + exit 1 +fi + +echo "Temp directory created: $cdk_itests" +export MINT_DATABASE="$1"; +export OPENID_DISCOVERY="$2"; + +cargo build -p cdk-integration-tests + +export CDK_MINTD_URL="http://$cdk_itests_mint_addr:$cdk_itests_mint_port"; +export CDK_MINTD_WORK_DIR="$cdk_itests"; +export CDK_MINTD_LISTEN_HOST=$cdk_itests_mint_addr; +export CDK_MINTD_LISTEN_PORT=$cdk_itests_mint_port; +export CDK_MINTD_LN_BACKEND="fakewallet"; +export CDK_MINTD_FAKE_WALLET_SUPPORTED_UNITS="sat"; +export CDK_MINTD_MNEMONIC="eye survey guilt napkin crystal cup whisper salt luggage manage unveil loyal"; +export CDK_MINTD_FAKE_WALLET_FEE_PERCENT="0"; +export CDK_MINTD_FAKE_WALLET_RESERVE_FEE_MIN="1"; +export CDK_MINTD_DATABASE=$MINT_DATABASE; + +# Auth configuration +export CDK_TEST_OIDC_USER="cdk-test"; +export CDK_TEST_OIDC_PASSWORD="cdkpassword"; + +export CDK_MINTD_AUTH_OPENID_DISCOVERY=$OPENID_DISCOVERY; +export CDK_MINTD_AUTH_OPENID_CLIENT_ID="cashu-client"; +export CDK_MINTD_AUTH_MINT_MAX_BAT="50"; +export CDK_MINTD_AUTH_ENABLED_MINT="true"; +export CDK_MINTD_AUTH_ENABLED_MELT="true"; +export CDK_MINTD_AUTH_ENABLED_SWAP="true"; +export CDK_MINTD_AUTH_ENABLED_CHECK_MINT_QUOTE="true"; +export CDK_MINTD_AUTH_ENABLED_CHECK_MELT_QUOTE="true"; +export CDK_MINTD_AUTH_ENABLED_RESTORE="true"; +export CDK_MINTD_AUTH_ENABLED_CHECK_PROOF_STATE="true"; + +echo "Starting auth mintd"; +cargo run --bin cdk-mintd --features redb & +cdk_mintd_pid=$! + +URL="http://$cdk_itests_mint_addr:$cdk_itests_mint_port/v1/info" +TIMEOUT=100 +START_TIME=$(date +%s) +# Loop until the endpoint returns a 200 OK status or timeout is reached +while true; do + # Get the current time + CURRENT_TIME=$(date +%s) + + # Calculate the elapsed time + ELAPSED_TIME=$((CURRENT_TIME - START_TIME)) + + # Check if the elapsed time exceeds the timeout + if [ $ELAPSED_TIME -ge $TIMEOUT ]; then + echo "Timeout of $TIMEOUT seconds reached. Exiting..." + exit 1 + fi + + # Make a request to the endpoint and capture the HTTP status code + HTTP_STATUS=$(curl -o /dev/null -s -w "%{http_code}" $URL) + + # Check if the HTTP status is 200 OK + if [ "$HTTP_STATUS" -eq 200 ]; then + echo "Received 200 OK from $URL" + break + else + echo "Waiting for 200 OK response, current status: $HTTP_STATUS" + sleep 2 # Wait for 2 seconds before retrying + fi +done + +# Run cargo test +cargo test -p cdk-integration-tests --test fake_auth + +# Capture the exit status of cargo test +test_status=$? + +# Exit with the status of the test +exit $test_status diff --git a/misc/keycloak/.env.example b/misc/keycloak/.env.example new file mode 100644 index 00000000..d193ae28 --- /dev/null +++ b/misc/keycloak/.env.example @@ -0,0 +1,7 @@ +POSTGRES_DB=keycloak_db +POSTGRES_USER=keycloak_db_user +POSTGRES_PASSWORD=keycloak_db_user_password +KEYCLOAK_ADMIN=admin +KEYCLOAK_ADMIN_PASSWORD=password +KC_HOSTNAME=localhost +KC_HOSTNAME_PORT=8080 diff --git a/misc/keycloak/docker-compose-recover.yml b/misc/keycloak/docker-compose-recover.yml new file mode 100644 index 00000000..eb9e95ea --- /dev/null +++ b/misc/keycloak/docker-compose-recover.yml @@ -0,0 +1,45 @@ +services: + postgres: + image: postgres:16.4 + volumes: + - ./postgres_data:/var/lib/postgresql/data + environment: + POSTGRES_DB: cashu + POSTGRES_USER: cashu + POSTGRES_PASSWORD: cashu + networks: + - keycloak_network + + keycloak: + image: quay.io/keycloak/keycloak:25.0.6 + command: start --import-realm + volumes: + - ./keycloak-export:/opt/keycloak/data/import + environment: + KC_HOSTNAME: localhost + KC_HOSTNAME_PORT: 8080 + KC_HOSTNAME_STRICT_BACKCHANNEL: false + KC_HTTP_ENABLED: true + KC_HOSTNAME_STRICT_HTTPS: false + KC_HEALTH_ENABLED: true + KEYCLOAK_ADMIN: admin + KEYCLOAK_ADMIN_PASSWORD: admin + KC_DB: postgres + KC_DB_URL: jdbc:postgresql://postgres/cashu + KC_DB_USERNAME: cashu + KC_DB_PASSWORD: cashu + ports: + - 8080:8080 + restart: always + depends_on: + - postgres + networks: + - keycloak_network + +volumes: + postgres_data: + driver: local + +networks: + keycloak_network: + driver: bridge diff --git a/misc/keycloak/docker-compose.yml b/misc/keycloak/docker-compose.yml new file mode 100644 index 00000000..55b33417 --- /dev/null +++ b/misc/keycloak/docker-compose.yml @@ -0,0 +1,43 @@ +services: + postgres: + image: postgres:16.4 + volumes: + - ./postgres_data:/var/lib/postgresql/data + environment: + POSTGRES_DB: ${POSTGRES_DB} + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + networks: + - keycloak_network + + keycloak: + image: quay.io/keycloak/keycloak:25.0.6 + command: start + environment: + KC_HOSTNAME: localhost + KC_HOSTNAME_PORT: 8080 + KC_HOSTNAME_STRICT_BACKCHANNEL: false + KC_HTTP_ENABLED: true + KC_HOSTNAME_STRICT_HTTPS: false + KC_HEALTH_ENABLED: true + KEYCLOAK_ADMIN: ${KEYCLOAK_ADMIN} + KEYCLOAK_ADMIN_PASSWORD: ${KEYCLOAK_ADMIN_PASSWORD} + KC_DB: postgres + KC_DB_URL: jdbc:postgresql://postgres/${POSTGRES_DB} + KC_DB_USERNAME: ${POSTGRES_USER} + KC_DB_PASSWORD: ${POSTGRES_PASSWORD} + ports: + - 8080:8080 + restart: always + depends_on: + - postgres + networks: + - keycloak_network + +volumes: + postgres_data: + driver: local + +networks: + keycloak_network: + driver: bridge diff --git a/misc/keycloak/keycloak-export/cdk-test-realm-realm.json b/misc/keycloak/keycloak-export/cdk-test-realm-realm.json new file mode 100644 index 00000000..1381586c --- /dev/null +++ b/misc/keycloak/keycloak-export/cdk-test-realm-realm.json @@ -0,0 +1,1854 @@ +{ + "id" : "3fad6a24-9b73-4af0-8783-37bbce843cc1", + "realm" : "cdk-test-realm", + "notBefore" : 0, + "defaultSignatureAlgorithm" : "RS256", + "revokeRefreshToken" : false, + "refreshTokenMaxReuse" : 0, + "accessTokenLifespan" : 300, + "accessTokenLifespanForImplicitFlow" : 900, + "ssoSessionIdleTimeout" : 1800, + "ssoSessionMaxLifespan" : 36000, + "ssoSessionIdleTimeoutRememberMe" : 0, + "ssoSessionMaxLifespanRememberMe" : 0, + "offlineSessionIdleTimeout" : 2592000, + "offlineSessionMaxLifespanEnabled" : false, + "offlineSessionMaxLifespan" : 5184000, + "clientSessionIdleTimeout" : 0, + "clientSessionMaxLifespan" : 0, + "clientOfflineSessionIdleTimeout" : 0, + "clientOfflineSessionMaxLifespan" : 0, + "accessCodeLifespan" : 60, + "accessCodeLifespanUserAction" : 300, + "accessCodeLifespanLogin" : 1800, + "actionTokenGeneratedByAdminLifespan" : 43200, + "actionTokenGeneratedByUserLifespan" : 300, + "oauth2DeviceCodeLifespan" : 600, + "oauth2DevicePollingInterval" : 5, + "enabled" : true, + "sslRequired" : "external", + "registrationAllowed" : false, + "registrationEmailAsUsername" : false, + "rememberMe" : false, + "verifyEmail" : false, + "loginWithEmailAllowed" : true, + "duplicateEmailsAllowed" : false, + "resetPasswordAllowed" : false, + "editUsernameAllowed" : false, + "bruteForceProtected" : false, + "permanentLockout" : false, + "maxTemporaryLockouts" : 0, + "maxFailureWaitSeconds" : 900, + "minimumQuickLoginWaitSeconds" : 60, + "waitIncrementSeconds" : 60, + "quickLoginCheckMilliSeconds" : 1000, + "maxDeltaTimeSeconds" : 43200, + "failureFactor" : 30, + "roles" : { + "realm" : [ { + "id" : "bd8f3ff8-e3f6-4ddf-8762-7575bcf0dec3", + "name" : "offline_access", + "description" : "${role_offline-access}", + "composite" : false, + "clientRole" : false, + "containerId" : "3fad6a24-9b73-4af0-8783-37bbce843cc1", + "attributes" : { } + }, { + "id" : "07917cba-c185-4227-9682-f7521eca6b23", + "name" : "uma_authorization", + "description" : "${role_uma_authorization}", + "composite" : false, + "clientRole" : false, + "containerId" : "3fad6a24-9b73-4af0-8783-37bbce843cc1", + "attributes" : { } + }, { + "id" : "9e67104e-f08e-49a1-901f-181806308108", + "name" : "default-roles-cdk-test-realm", + "description" : "${role_default-roles}", + "composite" : true, + "composites" : { + "realm" : [ "offline_access", "uma_authorization" ], + "client" : { + "account" : [ "manage-account", "view-profile" ] + } + }, + "clientRole" : false, + "containerId" : "3fad6a24-9b73-4af0-8783-37bbce843cc1", + "attributes" : { } + } ], + "client" : { + "realm-management" : [ { + "id" : "22583a81-4f11-4903-a49a-3b7f81c46964", + "name" : "view-identity-providers", + "description" : "${role_view-identity-providers}", + "composite" : false, + "clientRole" : true, + "containerId" : "045a12eb-239d-49a3-aa94-223445a9cd62", + "attributes" : { } + }, { + "id" : "00654d4f-7599-4d16-a0cc-3a01f48c62f4", + "name" : "manage-events", + "description" : "${role_manage-events}", + "composite" : false, + "clientRole" : true, + "containerId" : "045a12eb-239d-49a3-aa94-223445a9cd62", + "attributes" : { } + }, { + "id" : "784423ce-5517-4503-baa4-858ca2e03107", + "name" : "query-groups", + "description" : "${role_query-groups}", + "composite" : false, + "clientRole" : true, + "containerId" : "045a12eb-239d-49a3-aa94-223445a9cd62", + "attributes" : { } + }, { + "id" : "fe980061-84ea-4e0c-a261-a5a89978ede5", + "name" : "query-clients", + "description" : "${role_query-clients}", + "composite" : false, + "clientRole" : true, + "containerId" : "045a12eb-239d-49a3-aa94-223445a9cd62", + "attributes" : { } + }, { + "id" : "b93f38a8-3b77-4ac1-98a0-948422385e72", + "name" : "query-users", + "description" : "${role_query-users}", + "composite" : false, + "clientRole" : true, + "containerId" : "045a12eb-239d-49a3-aa94-223445a9cd62", + "attributes" : { } + }, { + "id" : "dc9d90be-7efe-4e39-b283-5389ad442dea", + "name" : "view-users", + "description" : "${role_view-users}", + "composite" : true, + "composites" : { + "client" : { + "realm-management" : [ "query-groups", "query-users" ] + } + }, + "clientRole" : true, + "containerId" : "045a12eb-239d-49a3-aa94-223445a9cd62", + "attributes" : { } + }, { + "id" : "18623d76-f70d-4783-b54f-a4c3d1e9242b", + "name" : "manage-realm", + "description" : "${role_manage-realm}", + "composite" : false, + "clientRole" : true, + "containerId" : "045a12eb-239d-49a3-aa94-223445a9cd62", + "attributes" : { } + }, { + "id" : "b34bacbd-1671-4846-bf0a-ac61c05706f2", + "name" : "create-client", + "description" : "${role_create-client}", + "composite" : false, + "clientRole" : true, + "containerId" : "045a12eb-239d-49a3-aa94-223445a9cd62", + "attributes" : { } + }, { + "id" : "7a262a9b-c813-461b-a65d-28c46ccdeb27", + "name" : "manage-clients", + "description" : "${role_manage-clients}", + "composite" : false, + "clientRole" : true, + "containerId" : "045a12eb-239d-49a3-aa94-223445a9cd62", + "attributes" : { } + }, { + "id" : "83167a63-06e2-4931-850e-e5a2bae8cfff", + "name" : "view-realm", + "description" : "${role_view-realm}", + "composite" : false, + "clientRole" : true, + "containerId" : "045a12eb-239d-49a3-aa94-223445a9cd62", + "attributes" : { } + }, { + "id" : "ee6628f5-c363-4f57-b6c0-6799817d9869", + "name" : "manage-authorization", + "description" : "${role_manage-authorization}", + "composite" : false, + "clientRole" : true, + "containerId" : "045a12eb-239d-49a3-aa94-223445a9cd62", + "attributes" : { } + }, { + "id" : "120a3584-4f41-4610-b172-32fc5c801a27", + "name" : "view-authorization", + "description" : "${role_view-authorization}", + "composite" : false, + "clientRole" : true, + "containerId" : "045a12eb-239d-49a3-aa94-223445a9cd62", + "attributes" : { } + }, { + "id" : "048d2848-8af8-4be4-a6dc-6118fe122410", + "name" : "realm-admin", + "description" : "${role_realm-admin}", + "composite" : true, + "composites" : { + "client" : { + "realm-management" : [ "view-identity-providers", "manage-events", "query-groups", "view-users", "query-users", "query-clients", "manage-realm", "create-client", "manage-clients", "view-realm", "view-authorization", "manage-authorization", "view-clients", "manage-identity-providers", "manage-users", "query-realms", "impersonation", "view-events" ] + } + }, + "clientRole" : true, + "containerId" : "045a12eb-239d-49a3-aa94-223445a9cd62", + "attributes" : { } + }, { + "id" : "682627b0-30c5-4d22-82b4-7b97ea19a800", + "name" : "view-clients", + "description" : "${role_view-clients}", + "composite" : true, + "composites" : { + "client" : { + "realm-management" : [ "query-clients" ] + } + }, + "clientRole" : true, + "containerId" : "045a12eb-239d-49a3-aa94-223445a9cd62", + "attributes" : { } + }, { + "id" : "26b578d4-5d14-406a-a063-e44467ca086d", + "name" : "manage-identity-providers", + "description" : "${role_manage-identity-providers}", + "composite" : false, + "clientRole" : true, + "containerId" : "045a12eb-239d-49a3-aa94-223445a9cd62", + "attributes" : { } + }, { + "id" : "7da25b21-4e29-42ee-a907-c37d3cb2bf5e", + "name" : "impersonation", + "description" : "${role_impersonation}", + "composite" : false, + "clientRole" : true, + "containerId" : "045a12eb-239d-49a3-aa94-223445a9cd62", + "attributes" : { } + }, { + "id" : "713711ac-c49f-42b0-b599-312a5fbaf98c", + "name" : "manage-users", + "description" : "${role_manage-users}", + "composite" : false, + "clientRole" : true, + "containerId" : "045a12eb-239d-49a3-aa94-223445a9cd62", + "attributes" : { } + }, { + "id" : "c9d16eec-3d99-4047-acc9-d754a5bc3440", + "name" : "query-realms", + "description" : "${role_query-realms}", + "composite" : false, + "clientRole" : true, + "containerId" : "045a12eb-239d-49a3-aa94-223445a9cd62", + "attributes" : { } + }, { + "id" : "d1722b7a-d4c6-4bf6-b159-84719d9a722f", + "name" : "view-events", + "description" : "${role_view-events}", + "composite" : false, + "clientRole" : true, + "containerId" : "045a12eb-239d-49a3-aa94-223445a9cd62", + "attributes" : { } + } ], + "security-admin-console" : [ ], + "admin-cli" : [ ], + "account-console" : [ ], + "broker" : [ { + "id" : "35ad0c49-1d97-48e6-a770-1d89e2f5bbc9", + "name" : "read-token", + "description" : "${role_read-token}", + "composite" : false, + "clientRole" : true, + "containerId" : "b3233571-3067-4aa9-9d34-bdff9295afba", + "attributes" : { } + } ], + "cashu-client" : [ ], + "account" : [ { + "id" : "25f3cc8e-9a2d-44eb-a521-6b8f78ca2137", + "name" : "view-groups", + "description" : "${role_view-groups}", + "composite" : false, + "clientRole" : true, + "containerId" : "1c3be8df-5121-41ac-94cb-dfbb44bffd0e", + "attributes" : { } + }, { + "id" : "bd21c4a5-451a-4794-a1cf-aef4a7c6bfd5", + "name" : "manage-account-links", + "description" : "${role_manage-account-links}", + "composite" : false, + "clientRole" : true, + "containerId" : "1c3be8df-5121-41ac-94cb-dfbb44bffd0e", + "attributes" : { } + }, { + "id" : "30024ee4-08be-4ff4-838b-3961069b10fc", + "name" : "delete-account", + "description" : "${role_delete-account}", + "composite" : false, + "clientRole" : true, + "containerId" : "1c3be8df-5121-41ac-94cb-dfbb44bffd0e", + "attributes" : { } + }, { + "id" : "866922c1-1e0a-4095-88b2-e8e4eae31977", + "name" : "view-consent", + "description" : "${role_view-consent}", + "composite" : false, + "clientRole" : true, + "containerId" : "1c3be8df-5121-41ac-94cb-dfbb44bffd0e", + "attributes" : { } + }, { + "id" : "cc791532-1f1a-4b5e-a6e2-153e8278e0fc", + "name" : "manage-account", + "description" : "${role_manage-account}", + "composite" : true, + "composites" : { + "client" : { + "account" : [ "manage-account-links" ] + } + }, + "clientRole" : true, + "containerId" : "1c3be8df-5121-41ac-94cb-dfbb44bffd0e", + "attributes" : { } + }, { + "id" : "b8786bf0-1768-4263-bbc8-2d122642e921", + "name" : "view-applications", + "description" : "${role_view-applications}", + "composite" : false, + "clientRole" : true, + "containerId" : "1c3be8df-5121-41ac-94cb-dfbb44bffd0e", + "attributes" : { } + }, { + "id" : "a10906d2-008e-4633-9e45-cc0bb1318d73", + "name" : "manage-consent", + "description" : "${role_manage-consent}", + "composite" : true, + "composites" : { + "client" : { + "account" : [ "view-consent" ] + } + }, + "clientRole" : true, + "containerId" : "1c3be8df-5121-41ac-94cb-dfbb44bffd0e", + "attributes" : { } + }, { + "id" : "a8052816-53d5-4902-bb82-c48362fbba93", + "name" : "view-profile", + "description" : "${role_view-profile}", + "composite" : false, + "clientRole" : true, + "containerId" : "1c3be8df-5121-41ac-94cb-dfbb44bffd0e", + "attributes" : { } + } ] + } + }, + "groups" : [ ], + "defaultRole" : { + "id" : "9e67104e-f08e-49a1-901f-181806308108", + "name" : "default-roles-cdk-test-realm", + "description" : "${role_default-roles}", + "composite" : true, + "clientRole" : false, + "containerId" : "3fad6a24-9b73-4af0-8783-37bbce843cc1" + }, + "requiredCredentials" : [ "password" ], + "otpPolicyType" : "totp", + "otpPolicyAlgorithm" : "HmacSHA1", + "otpPolicyInitialCounter" : 0, + "otpPolicyDigits" : 6, + "otpPolicyLookAheadWindow" : 1, + "otpPolicyPeriod" : 30, + "otpPolicyCodeReusable" : false, + "otpSupportedApplications" : [ "totpAppFreeOTPName", "totpAppGoogleName", "totpAppMicrosoftAuthenticatorName" ], + "localizationTexts" : { }, + "webAuthnPolicyRpEntityName" : "keycloak", + "webAuthnPolicySignatureAlgorithms" : [ "ES256" ], + "webAuthnPolicyRpId" : "", + "webAuthnPolicyAttestationConveyancePreference" : "not specified", + "webAuthnPolicyAuthenticatorAttachment" : "not specified", + "webAuthnPolicyRequireResidentKey" : "not specified", + "webAuthnPolicyUserVerificationRequirement" : "not specified", + "webAuthnPolicyCreateTimeout" : 0, + "webAuthnPolicyAvoidSameAuthenticatorRegister" : false, + "webAuthnPolicyAcceptableAaguids" : [ ], + "webAuthnPolicyExtraOrigins" : [ ], + "webAuthnPolicyPasswordlessRpEntityName" : "keycloak", + "webAuthnPolicyPasswordlessSignatureAlgorithms" : [ "ES256" ], + "webAuthnPolicyPasswordlessRpId" : "", + "webAuthnPolicyPasswordlessAttestationConveyancePreference" : "not specified", + "webAuthnPolicyPasswordlessAuthenticatorAttachment" : "not specified", + "webAuthnPolicyPasswordlessRequireResidentKey" : "not specified", + "webAuthnPolicyPasswordlessUserVerificationRequirement" : "not specified", + "webAuthnPolicyPasswordlessCreateTimeout" : 0, + "webAuthnPolicyPasswordlessAvoidSameAuthenticatorRegister" : false, + "webAuthnPolicyPasswordlessAcceptableAaguids" : [ ], + "webAuthnPolicyPasswordlessExtraOrigins" : [ ], + "scopeMappings" : [ { + "clientScope" : "offline_access", + "roles" : [ "offline_access" ] + } ], + "clientScopeMappings" : { + "account" : [ { + "client" : "account-console", + "roles" : [ "manage-account", "view-groups" ] + } ] + }, + "clients" : [ { + "id" : "1c3be8df-5121-41ac-94cb-dfbb44bffd0e", + "clientId" : "account", + "name" : "${client_account}", + "rootUrl" : "${authBaseUrl}", + "baseUrl" : "/realms/cdk-test-realm/account/", + "surrogateAuthRequired" : false, + "enabled" : true, + "alwaysDisplayInConsole" : false, + "clientAuthenticatorType" : "client-secret", + "redirectUris" : [ "/realms/cdk-test-realm/account/*" ], + "webOrigins" : [ ], + "notBefore" : 0, + "bearerOnly" : false, + "consentRequired" : false, + "standardFlowEnabled" : true, + "implicitFlowEnabled" : false, + "directAccessGrantsEnabled" : false, + "serviceAccountsEnabled" : false, + "publicClient" : true, + "frontchannelLogout" : false, + "protocol" : "openid-connect", + "attributes" : { + "post.logout.redirect.uris" : "+" + }, + "authenticationFlowBindingOverrides" : { }, + "fullScopeAllowed" : false, + "nodeReRegistrationTimeout" : 0, + "defaultClientScopes" : [ "web-origins", "acr", "profile", "roles", "basic", "email" ], + "optionalClientScopes" : [ "address", "phone", "offline_access", "microprofile-jwt" ] + }, { + "id" : "eab17087-ab47-40b0-b161-6ca73367eb73", + "clientId" : "account-console", + "name" : "${client_account-console}", + "rootUrl" : "${authBaseUrl}", + "baseUrl" : "/realms/cdk-test-realm/account/", + "surrogateAuthRequired" : false, + "enabled" : true, + "alwaysDisplayInConsole" : false, + "clientAuthenticatorType" : "client-secret", + "redirectUris" : [ "/realms/cdk-test-realm/account/*" ], + "webOrigins" : [ ], + "notBefore" : 0, + "bearerOnly" : false, + "consentRequired" : false, + "standardFlowEnabled" : true, + "implicitFlowEnabled" : false, + "directAccessGrantsEnabled" : false, + "serviceAccountsEnabled" : false, + "publicClient" : true, + "frontchannelLogout" : false, + "protocol" : "openid-connect", + "attributes" : { + "post.logout.redirect.uris" : "+", + "pkce.code.challenge.method" : "S256" + }, + "authenticationFlowBindingOverrides" : { }, + "fullScopeAllowed" : false, + "nodeReRegistrationTimeout" : 0, + "protocolMappers" : [ { + "id" : "0ee18b2b-a731-47d5-9942-30a2a46fd6fc", + "name" : "audience resolve", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-audience-resolve-mapper", + "consentRequired" : false, + "config" : { } + } ], + "defaultClientScopes" : [ "web-origins", "acr", "profile", "roles", "basic", "email" ], + "optionalClientScopes" : [ "address", "phone", "offline_access", "microprofile-jwt" ] + }, { + "id" : "8deccf4a-34cb-41f6-a891-dfabdf0bf73e", + "clientId" : "admin-cli", + "name" : "${client_admin-cli}", + "surrogateAuthRequired" : false, + "enabled" : true, + "alwaysDisplayInConsole" : false, + "clientAuthenticatorType" : "client-secret", + "redirectUris" : [ ], + "webOrigins" : [ ], + "notBefore" : 0, + "bearerOnly" : false, + "consentRequired" : false, + "standardFlowEnabled" : false, + "implicitFlowEnabled" : false, + "directAccessGrantsEnabled" : true, + "serviceAccountsEnabled" : false, + "publicClient" : true, + "frontchannelLogout" : false, + "protocol" : "openid-connect", + "attributes" : { }, + "authenticationFlowBindingOverrides" : { }, + "fullScopeAllowed" : false, + "nodeReRegistrationTimeout" : 0, + "defaultClientScopes" : [ "web-origins", "acr", "profile", "roles", "basic", "email" ], + "optionalClientScopes" : [ "address", "phone", "offline_access", "microprofile-jwt" ] + }, { + "id" : "b3233571-3067-4aa9-9d34-bdff9295afba", + "clientId" : "broker", + "name" : "${client_broker}", + "surrogateAuthRequired" : false, + "enabled" : true, + "alwaysDisplayInConsole" : false, + "clientAuthenticatorType" : "client-secret", + "redirectUris" : [ ], + "webOrigins" : [ ], + "notBefore" : 0, + "bearerOnly" : true, + "consentRequired" : false, + "standardFlowEnabled" : true, + "implicitFlowEnabled" : false, + "directAccessGrantsEnabled" : false, + "serviceAccountsEnabled" : false, + "publicClient" : false, + "frontchannelLogout" : false, + "protocol" : "openid-connect", + "attributes" : { }, + "authenticationFlowBindingOverrides" : { }, + "fullScopeAllowed" : false, + "nodeReRegistrationTimeout" : 0, + "defaultClientScopes" : [ "web-origins", "acr", "profile", "roles", "basic", "email" ], + "optionalClientScopes" : [ "address", "phone", "offline_access", "microprofile-jwt" ] + }, { + "id" : "0d2671d1-64e0-41e6-9452-831b9141e760", + "clientId" : "cashu-client", + "name" : "", + "description" : "", + "rootUrl" : "", + "adminUrl" : "", + "baseUrl" : "", + "surrogateAuthRequired" : false, + "enabled" : true, + "alwaysDisplayInConsole" : false, + "clientAuthenticatorType" : "client-secret", + "redirectUris" : [ "http://localhost:33388/callback" ], + "webOrigins" : [ "http://localhost:33388" ], + "notBefore" : 0, + "bearerOnly" : false, + "consentRequired" : false, + "standardFlowEnabled" : true, + "implicitFlowEnabled" : true, + "directAccessGrantsEnabled" : true, + "serviceAccountsEnabled" : false, + "publicClient" : true, + "frontchannelLogout" : true, + "protocol" : "openid-connect", + "attributes" : { + "oidc.ciba.grant.enabled" : "false", + "backchannel.logout.session.required" : "true", + "display.on.consent.screen" : "false", + "oauth2.device.authorization.grant.enabled" : "true", + "backchannel.logout.revoke.offline.tokens" : "false" + }, + "authenticationFlowBindingOverrides" : { }, + "fullScopeAllowed" : true, + "nodeReRegistrationTimeout" : -1, + "defaultClientScopes" : [ "web-origins", "acr", "profile", "roles", "basic", "email" ], + "optionalClientScopes" : [ "address", "phone", "offline_access", "microprofile-jwt" ] + }, { + "id" : "045a12eb-239d-49a3-aa94-223445a9cd62", + "clientId" : "realm-management", + "name" : "${client_realm-management}", + "surrogateAuthRequired" : false, + "enabled" : true, + "alwaysDisplayInConsole" : false, + "clientAuthenticatorType" : "client-secret", + "redirectUris" : [ ], + "webOrigins" : [ ], + "notBefore" : 0, + "bearerOnly" : true, + "consentRequired" : false, + "standardFlowEnabled" : true, + "implicitFlowEnabled" : false, + "directAccessGrantsEnabled" : false, + "serviceAccountsEnabled" : false, + "publicClient" : false, + "frontchannelLogout" : false, + "protocol" : "openid-connect", + "attributes" : { }, + "authenticationFlowBindingOverrides" : { }, + "fullScopeAllowed" : false, + "nodeReRegistrationTimeout" : 0, + "defaultClientScopes" : [ "web-origins", "acr", "profile", "roles", "basic", "email" ], + "optionalClientScopes" : [ "address", "phone", "offline_access", "microprofile-jwt" ] + }, { + "id" : "a8a6173e-3305-45b6-85b7-9f046a01d002", + "clientId" : "security-admin-console", + "name" : "${client_security-admin-console}", + "rootUrl" : "${authAdminUrl}", + "baseUrl" : "/admin/cdk-test-realm/console/", + "surrogateAuthRequired" : false, + "enabled" : true, + "alwaysDisplayInConsole" : false, + "clientAuthenticatorType" : "client-secret", + "redirectUris" : [ "/admin/cdk-test-realm/console/*" ], + "webOrigins" : [ "+" ], + "notBefore" : 0, + "bearerOnly" : false, + "consentRequired" : false, + "standardFlowEnabled" : true, + "implicitFlowEnabled" : false, + "directAccessGrantsEnabled" : false, + "serviceAccountsEnabled" : false, + "publicClient" : true, + "frontchannelLogout" : false, + "protocol" : "openid-connect", + "attributes" : { + "post.logout.redirect.uris" : "+", + "pkce.code.challenge.method" : "S256" + }, + "authenticationFlowBindingOverrides" : { }, + "fullScopeAllowed" : false, + "nodeReRegistrationTimeout" : 0, + "protocolMappers" : [ { + "id" : "bda8f436-f225-4d2a-b558-63d45c972c34", + "name" : "locale", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usermodel-attribute-mapper", + "consentRequired" : false, + "config" : { + "introspection.token.claim" : "true", + "userinfo.token.claim" : "true", + "user.attribute" : "locale", + "id.token.claim" : "true", + "access.token.claim" : "true", + "claim.name" : "locale", + "jsonType.label" : "String" + } + } ], + "defaultClientScopes" : [ "web-origins", "acr", "profile", "roles", "basic", "email" ], + "optionalClientScopes" : [ "address", "phone", "offline_access", "microprofile-jwt" ] + } ], + "clientScopes" : [ { + "id" : "5f75fc73-73da-4320-90d7-d0af22e30910", + "name" : "profile", + "description" : "OpenID Connect built-in scope: profile", + "protocol" : "openid-connect", + "attributes" : { + "include.in.token.scope" : "true", + "consent.screen.text" : "${profileScopeConsentText}", + "display.on.consent.screen" : "true" + }, + "protocolMappers" : [ { + "id" : "69c0828e-c3f4-475b-b88a-2559793aab45", + "name" : "website", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usermodel-attribute-mapper", + "consentRequired" : false, + "config" : { + "introspection.token.claim" : "true", + "userinfo.token.claim" : "true", + "user.attribute" : "website", + "id.token.claim" : "true", + "access.token.claim" : "true", + "claim.name" : "website", + "jsonType.label" : "String" + } + }, { + "id" : "b70a9bb2-f139-46de-864f-73ddb8f99658", + "name" : "middle name", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usermodel-attribute-mapper", + "consentRequired" : false, + "config" : { + "introspection.token.claim" : "true", + "userinfo.token.claim" : "true", + "user.attribute" : "middleName", + "id.token.claim" : "true", + "access.token.claim" : "true", + "claim.name" : "middle_name", + "jsonType.label" : "String" + } + }, { + "id" : "8b4ddf79-451f-45e0-8f25-e912adcb3fb5", + "name" : "zoneinfo", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usermodel-attribute-mapper", + "consentRequired" : false, + "config" : { + "introspection.token.claim" : "true", + "userinfo.token.claim" : "true", + "user.attribute" : "zoneinfo", + "id.token.claim" : "true", + "access.token.claim" : "true", + "claim.name" : "zoneinfo", + "jsonType.label" : "String" + } + }, { + "id" : "6e4d639f-2920-4b4e-bc36-7fad9cef368d", + "name" : "nickname", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usermodel-attribute-mapper", + "consentRequired" : false, + "config" : { + "introspection.token.claim" : "true", + "userinfo.token.claim" : "true", + "user.attribute" : "nickname", + "id.token.claim" : "true", + "access.token.claim" : "true", + "claim.name" : "nickname", + "jsonType.label" : "String" + } + }, { + "id" : "5cc6e80f-0d41-49ae-a7b9-6e76401ebb8b", + "name" : "gender", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usermodel-attribute-mapper", + "consentRequired" : false, + "config" : { + "introspection.token.claim" : "true", + "userinfo.token.claim" : "true", + "user.attribute" : "gender", + "id.token.claim" : "true", + "access.token.claim" : "true", + "claim.name" : "gender", + "jsonType.label" : "String" + } + }, { + "id" : "e9a69c26-0d4c-4a37-b7d1-8c611ad17596", + "name" : "family name", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usermodel-attribute-mapper", + "consentRequired" : false, + "config" : { + "introspection.token.claim" : "true", + "userinfo.token.claim" : "true", + "user.attribute" : "lastName", + "id.token.claim" : "true", + "access.token.claim" : "true", + "claim.name" : "family_name", + "jsonType.label" : "String" + } + }, { + "id" : "a646d05c-03bf-4b68-a18e-19cb630e773a", + "name" : "given name", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usermodel-attribute-mapper", + "consentRequired" : false, + "config" : { + "introspection.token.claim" : "true", + "userinfo.token.claim" : "true", + "user.attribute" : "firstName", + "id.token.claim" : "true", + "access.token.claim" : "true", + "claim.name" : "given_name", + "jsonType.label" : "String" + } + }, { + "id" : "bf87b714-b41e-40ba-a748-7d4619a6add0", + "name" : "birthdate", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usermodel-attribute-mapper", + "consentRequired" : false, + "config" : { + "introspection.token.claim" : "true", + "userinfo.token.claim" : "true", + "user.attribute" : "birthdate", + "id.token.claim" : "true", + "access.token.claim" : "true", + "claim.name" : "birthdate", + "jsonType.label" : "String" + } + }, { + "id" : "19f5f26d-ba31-48c9-9376-2fc72bb775ee", + "name" : "picture", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usermodel-attribute-mapper", + "consentRequired" : false, + "config" : { + "introspection.token.claim" : "true", + "userinfo.token.claim" : "true", + "user.attribute" : "picture", + "id.token.claim" : "true", + "access.token.claim" : "true", + "claim.name" : "picture", + "jsonType.label" : "String" + } + }, { + "id" : "ec4c72ca-6b5d-48d0-a0f5-1716d90cbf3e", + "name" : "locale", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usermodel-attribute-mapper", + "consentRequired" : false, + "config" : { + "introspection.token.claim" : "true", + "userinfo.token.claim" : "true", + "user.attribute" : "locale", + "id.token.claim" : "true", + "access.token.claim" : "true", + "claim.name" : "locale", + "jsonType.label" : "String" + } + }, { + "id" : "cd396fdb-6b22-406c-a9c4-ec2a896b7af7", + "name" : "full name", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-full-name-mapper", + "consentRequired" : false, + "config" : { + "id.token.claim" : "true", + "introspection.token.claim" : "true", + "access.token.claim" : "true", + "userinfo.token.claim" : "true" + } + }, { + "id" : "42fca5db-93a6-4dd4-afe6-2d04c16fc7fe", + "name" : "profile", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usermodel-attribute-mapper", + "consentRequired" : false, + "config" : { + "introspection.token.claim" : "true", + "userinfo.token.claim" : "true", + "user.attribute" : "profile", + "id.token.claim" : "true", + "access.token.claim" : "true", + "claim.name" : "profile", + "jsonType.label" : "String" + } + }, { + "id" : "9406148d-5b7b-48cc-a97f-3d6d7037144e", + "name" : "updated at", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usermodel-attribute-mapper", + "consentRequired" : false, + "config" : { + "introspection.token.claim" : "true", + "userinfo.token.claim" : "true", + "user.attribute" : "updatedAt", + "id.token.claim" : "true", + "access.token.claim" : "true", + "claim.name" : "updated_at", + "jsonType.label" : "long" + } + }, { + "id" : "b746da15-c007-450f-a8b7-0e0d60688b34", + "name" : "username", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usermodel-attribute-mapper", + "consentRequired" : false, + "config" : { + "introspection.token.claim" : "true", + "userinfo.token.claim" : "true", + "user.attribute" : "username", + "id.token.claim" : "true", + "access.token.claim" : "true", + "claim.name" : "preferred_username", + "jsonType.label" : "String" + } + } ] + }, { + "id" : "768deb55-abef-4851-8e8b-d120527bc653", + "name" : "roles", + "description" : "OpenID Connect scope for add user roles to the access token", + "protocol" : "openid-connect", + "attributes" : { + "include.in.token.scope" : "false", + "consent.screen.text" : "${rolesScopeConsentText}", + "display.on.consent.screen" : "true" + }, + "protocolMappers" : [ { + "id" : "c78ae6aa-c4aa-4853-afbd-0dbeddd216aa", + "name" : "realm roles", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usermodel-realm-role-mapper", + "consentRequired" : false, + "config" : { + "user.attribute" : "foo", + "introspection.token.claim" : "true", + "access.token.claim" : "true", + "claim.name" : "realm_access.roles", + "jsonType.label" : "String", + "multivalued" : "true" + } + }, { + "id" : "e64996ed-2368-4329-912c-8876f1b62854", + "name" : "audience resolve", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-audience-resolve-mapper", + "consentRequired" : false, + "config" : { + "introspection.token.claim" : "true", + "access.token.claim" : "true" + } + }, { + "id" : "19b4bc03-0f43-499e-8942-b2555a3fa078", + "name" : "client roles", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usermodel-client-role-mapper", + "consentRequired" : false, + "config" : { + "user.attribute" : "foo", + "introspection.token.claim" : "true", + "access.token.claim" : "true", + "claim.name" : "resource_access.${client_id}.roles", + "jsonType.label" : "String", + "multivalued" : "true" + } + } ] + }, { + "id" : "4cba0f6d-9b91-4069-88aa-22233ba828e6", + "name" : "basic", + "description" : "OpenID Connect scope for add all basic claims to the token", + "protocol" : "openid-connect", + "attributes" : { + "include.in.token.scope" : "false", + "display.on.consent.screen" : "false" + }, + "protocolMappers" : [ { + "id" : "62149b93-b257-471e-8472-a60af4faff5f", + "name" : "sub", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-sub-mapper", + "consentRequired" : false, + "config" : { + "introspection.token.claim" : "true", + "access.token.claim" : "true" + } + }, { + "id" : "7942d037-3c0a-4fc2-843c-f6396ddad8a4", + "name" : "auth_time", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usersessionmodel-note-mapper", + "consentRequired" : false, + "config" : { + "user.session.note" : "AUTH_TIME", + "id.token.claim" : "true", + "introspection.token.claim" : "true", + "access.token.claim" : "true", + "claim.name" : "auth_time", + "jsonType.label" : "long" + } + } ] + }, { + "id" : "d7a691ca-1c47-4be2-a4b1-8d536cbb8c5b", + "name" : "role_list", + "description" : "SAML role list", + "protocol" : "saml", + "attributes" : { + "consent.screen.text" : "${samlRoleListScopeConsentText}", + "display.on.consent.screen" : "true" + }, + "protocolMappers" : [ { + "id" : "eaec748e-ab09-4d6d-a357-6e26ddf17813", + "name" : "role list", + "protocol" : "saml", + "protocolMapper" : "saml-role-list-mapper", + "consentRequired" : false, + "config" : { + "single" : "false", + "attribute.nameformat" : "Basic", + "attribute.name" : "Role" + } + } ] + }, { + "id" : "7728ba47-f334-47d7-9d9e-34c3c4e3347f", + "name" : "acr", + "description" : "OpenID Connect scope for add acr (authentication context class reference) to the token", + "protocol" : "openid-connect", + "attributes" : { + "include.in.token.scope" : "false", + "display.on.consent.screen" : "false" + }, + "protocolMappers" : [ { + "id" : "db0001ac-aae9-4658-8fbc-d1b188cf9a8c", + "name" : "acr loa level", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-acr-mapper", + "consentRequired" : false, + "config" : { + "id.token.claim" : "true", + "introspection.token.claim" : "true", + "access.token.claim" : "true" + } + } ] + }, { + "id" : "c6db8233-847f-465b-9aa0-e71d84e30598", + "name" : "microprofile-jwt", + "description" : "Microprofile - JWT built-in scope", + "protocol" : "openid-connect", + "attributes" : { + "include.in.token.scope" : "true", + "display.on.consent.screen" : "false" + }, + "protocolMappers" : [ { + "id" : "ebf379c0-afcb-41e5-9dc3-f94a05d2e7d8", + "name" : "groups", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usermodel-realm-role-mapper", + "consentRequired" : false, + "config" : { + "introspection.token.claim" : "true", + "multivalued" : "true", + "user.attribute" : "foo", + "id.token.claim" : "true", + "access.token.claim" : "true", + "claim.name" : "groups", + "jsonType.label" : "String" + } + }, { + "id" : "ee6b260e-d926-4594-8774-0b3e141a9a4d", + "name" : "upn", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usermodel-attribute-mapper", + "consentRequired" : false, + "config" : { + "introspection.token.claim" : "true", + "userinfo.token.claim" : "true", + "user.attribute" : "username", + "id.token.claim" : "true", + "access.token.claim" : "true", + "claim.name" : "upn", + "jsonType.label" : "String" + } + } ] + }, { + "id" : "52cd53e2-6596-4b1c-93cd-52043912edb2", + "name" : "address", + "description" : "OpenID Connect built-in scope: address", + "protocol" : "openid-connect", + "attributes" : { + "include.in.token.scope" : "true", + "consent.screen.text" : "${addressScopeConsentText}", + "display.on.consent.screen" : "true" + }, + "protocolMappers" : [ { + "id" : "c00d29d6-f183-4719-8fb7-f128ab51c6da", + "name" : "address", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-address-mapper", + "consentRequired" : false, + "config" : { + "user.attribute.formatted" : "formatted", + "user.attribute.country" : "country", + "introspection.token.claim" : "true", + "user.attribute.postal_code" : "postal_code", + "userinfo.token.claim" : "true", + "user.attribute.street" : "street", + "id.token.claim" : "true", + "user.attribute.region" : "region", + "access.token.claim" : "true", + "user.attribute.locality" : "locality" + } + } ] + }, { + "id" : "08c05fd2-21f5-4dff-be80-1b5414e60fac", + "name" : "phone", + "description" : "OpenID Connect built-in scope: phone", + "protocol" : "openid-connect", + "attributes" : { + "include.in.token.scope" : "true", + "consent.screen.text" : "${phoneScopeConsentText}", + "display.on.consent.screen" : "true" + }, + "protocolMappers" : [ { + "id" : "02493dac-b5de-4c9e-9619-a29c539cfb76", + "name" : "phone number verified", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usermodel-attribute-mapper", + "consentRequired" : false, + "config" : { + "introspection.token.claim" : "true", + "userinfo.token.claim" : "true", + "user.attribute" : "phoneNumberVerified", + "id.token.claim" : "true", + "access.token.claim" : "true", + "claim.name" : "phone_number_verified", + "jsonType.label" : "boolean" + } + }, { + "id" : "3472ce05-651d-4dee-ac06-23e51d813666", + "name" : "phone number", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usermodel-attribute-mapper", + "consentRequired" : false, + "config" : { + "introspection.token.claim" : "true", + "userinfo.token.claim" : "true", + "user.attribute" : "phoneNumber", + "id.token.claim" : "true", + "access.token.claim" : "true", + "claim.name" : "phone_number", + "jsonType.label" : "String" + } + } ] + }, { + "id" : "cc47edbe-7278-4fea-a5be-5bcb38dd5767", + "name" : "offline_access", + "description" : "OpenID Connect built-in scope: offline_access", + "protocol" : "openid-connect", + "attributes" : { + "consent.screen.text" : "${offlineAccessScopeConsentText}", + "display.on.consent.screen" : "true" + } + }, { + "id" : "618f9380-73fd-49c0-b5e1-e6952b586afe", + "name" : "email", + "description" : "OpenID Connect built-in scope: email", + "protocol" : "openid-connect", + "attributes" : { + "include.in.token.scope" : "true", + "consent.screen.text" : "${emailScopeConsentText}", + "display.on.consent.screen" : "true" + }, + "protocolMappers" : [ { + "id" : "a3b226e3-0bb1-456a-ace7-d8c53f7d7917", + "name" : "email", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usermodel-attribute-mapper", + "consentRequired" : false, + "config" : { + "introspection.token.claim" : "true", + "userinfo.token.claim" : "true", + "user.attribute" : "email", + "id.token.claim" : "true", + "access.token.claim" : "true", + "claim.name" : "email", + "jsonType.label" : "String" + } + }, { + "id" : "3a8eb1c8-9e8e-4d3b-a44e-f40e3ff95414", + "name" : "email verified", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usermodel-property-mapper", + "consentRequired" : false, + "config" : { + "introspection.token.claim" : "true", + "userinfo.token.claim" : "true", + "user.attribute" : "emailVerified", + "id.token.claim" : "true", + "access.token.claim" : "true", + "claim.name" : "email_verified", + "jsonType.label" : "boolean" + } + } ] + }, { + "id" : "c299c8f8-c61e-421e-abfc-d75e9f0bbf0b", + "name" : "web-origins", + "description" : "OpenID Connect scope for add allowed web origins to the access token", + "protocol" : "openid-connect", + "attributes" : { + "include.in.token.scope" : "false", + "consent.screen.text" : "", + "display.on.consent.screen" : "false" + }, + "protocolMappers" : [ { + "id" : "7dbe8157-0542-431a-896a-67e2820e7502", + "name" : "allowed web origins", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-allowed-origins-mapper", + "consentRequired" : false, + "config" : { + "introspection.token.claim" : "true", + "access.token.claim" : "true" + } + } ] + } ], + "defaultDefaultClientScopes" : [ "role_list", "profile", "email", "roles", "web-origins", "acr", "basic" ], + "defaultOptionalClientScopes" : [ "offline_access", "address", "phone", "microprofile-jwt" ], + "browserSecurityHeaders" : { + "contentSecurityPolicyReportOnly" : "", + "xContentTypeOptions" : "nosniff", + "referrerPolicy" : "no-referrer", + "xRobotsTag" : "none", + "xFrameOptions" : "SAMEORIGIN", + "contentSecurityPolicy" : "frame-src 'self'; frame-ancestors 'self'; object-src 'none';", + "xXSSProtection" : "1; mode=block", + "strictTransportSecurity" : "max-age=31536000; includeSubDomains" + }, + "smtpServer" : { }, + "eventsEnabled" : false, + "eventsListeners" : [ "jboss-logging" ], + "enabledEventTypes" : [ ], + "adminEventsEnabled" : false, + "adminEventsDetailsEnabled" : false, + "identityProviders" : [ ], + "identityProviderMappers" : [ ], + "components" : { + "org.keycloak.services.clientregistration.policy.ClientRegistrationPolicy" : [ { + "id" : "5927428e-6b23-487a-b923-d033a677e54c", + "name" : "Consent Required", + "providerId" : "consent-required", + "subType" : "anonymous", + "subComponents" : { }, + "config" : { } + }, { + "id" : "71cf7845-b1fe-4a7f-bfa7-e9091474a7a5", + "name" : "Allowed Protocol Mapper Types", + "providerId" : "allowed-protocol-mappers", + "subType" : "authenticated", + "subComponents" : { }, + "config" : { + "allowed-protocol-mapper-types" : [ "saml-user-attribute-mapper", "oidc-usermodel-property-mapper", "oidc-full-name-mapper", "saml-role-list-mapper", "oidc-usermodel-attribute-mapper", "saml-user-property-mapper", "oidc-sha256-pairwise-sub-mapper", "oidc-address-mapper" ] + } + }, { + "id" : "df1baed8-b756-4721-b84b-78d651dcdd7c", + "name" : "Allowed Client Scopes", + "providerId" : "allowed-client-templates", + "subType" : "anonymous", + "subComponents" : { }, + "config" : { + "allow-default-scopes" : [ "true" ] + } + }, { + "id" : "b80d8432-4258-4949-9a96-d9e226b51a38", + "name" : "Max Clients Limit", + "providerId" : "max-clients", + "subType" : "anonymous", + "subComponents" : { }, + "config" : { + "max-clients" : [ "200" ] + } + }, { + "id" : "31d123c3-fd98-4b5c-ac62-bb2bfb28b128", + "name" : "Full Scope Disabled", + "providerId" : "scope", + "subType" : "anonymous", + "subComponents" : { }, + "config" : { } + }, { + "id" : "16863582-ef20-439b-ac3a-113c16ab0220", + "name" : "Trusted Hosts", + "providerId" : "trusted-hosts", + "subType" : "anonymous", + "subComponents" : { }, + "config" : { + "host-sending-registration-request-must-match" : [ "true" ], + "client-uris-must-match" : [ "true" ] + } + }, { + "id" : "5ef4e1e3-1f9b-4ff0-813b-ebe5e70ce3a1", + "name" : "Allowed Protocol Mapper Types", + "providerId" : "allowed-protocol-mappers", + "subType" : "anonymous", + "subComponents" : { }, + "config" : { + "allowed-protocol-mapper-types" : [ "oidc-usermodel-attribute-mapper", "saml-user-property-mapper", "oidc-full-name-mapper", "oidc-sha256-pairwise-sub-mapper", "oidc-address-mapper", "saml-role-list-mapper", "oidc-usermodel-property-mapper", "saml-user-attribute-mapper" ] + } + }, { + "id" : "7678b862-a5e0-4cda-b1a9-378e6751e3c4", + "name" : "Allowed Client Scopes", + "providerId" : "allowed-client-templates", + "subType" : "authenticated", + "subComponents" : { }, + "config" : { + "allow-default-scopes" : [ "true" ] + } + } ], + "org.keycloak.keys.KeyProvider" : [ { + "id" : "e5f25529-37d9-43aa-af07-678b142d6105", + "name" : "hmac-generated-hs512", + "providerId" : "hmac-generated", + "subComponents" : { }, + "config" : { + "kid" : [ "11c078c4-a4b6-4c29-b0ce-3bdcac91dd26" ], + "secret" : [ "_kU_uMAI7-LAKpReKVCngWOQxzpUZWFFF1QbLI3TNny-2zQTeB8kD2WiFOdt3pH_TEVR7nAgiVuvcASjYoqXDYmX8BJQlX9cA39TmAkGOSkViZXU_ufWRGam6M7svHvKTr2GwYQ7WrdmO4mOQogUOYFm9F8-RGXW01d2ACIg4g8" ], + "priority" : [ "100" ], + "algorithm" : [ "HS512" ] + } + }, { + "id" : "5ba40a03-42dc-4caf-87df-ba7125e16d16", + "name" : "rsa-generated", + "providerId" : "rsa-generated", + "subComponents" : { }, + "config" : { + "privateKey" : [ "MIIEpAIBAAKCAQEAu0fLOomgHoAuBIuz1+jT6jij76az4U0o4xZRZRL+w9CrubLWVdXNyMrMtiRUgcqJU2/qV2CHi2NVRLLGvsBHOzvlFJ7vNSRH1r2GyzQwD3En2x3y/AB0L/e0qBl5irUYcPDG+4+VFqv9jms1E1pIvCsEDmw6EPUqM29C7oRCOEDw91EvxvE76VCKhEMdX/9FWe3b91bYXwyHolkpa+unlDmzO47AAtTpL6KJsggDyKXuI3+en7KcVdj/Vu19q2MaN8XVep2sUi5oYhcqS6e5+2bTyBN4u3PjKujx/AJEMzSWsVBP3Sb7zWq1LKNHeunKVkawP/ZZCHyK+L+FigZIUQIDAQABAoIBAD37BTny2VCu2Ev91j/TGOtP4oWKVMbwS+NX/Ako77LrqSG0m7XdpBTbdUOko2kvGCLKi7JuSn2pmss5JYh0xz5F1MttzDn4r2PU5rryoyNBLlEYyNV46vsDDkGB6NUsmRGrxhb0ToGp2ykatv6YJzFLRTTmwZLiKJzMKkkY8Xd/CYr/d/srqYMMArIKkVQuw+u4vA4qd78cLFLZqB/+d7UmtDXmXRuG512T805ODlWf8ouQuDO4UE5h7EkPmPw7EJoKoxMv/GIhNch6Uho1Yct19uhOGOQnOIMQjpd/eU3gDegIlwcuSm+xNuV+0EA2Vm8qQdK91GgECSOM0kW7NO8CgYEA5Tumbov2b35vBeFXBYJ83Zf27Sfi2Jtqoa+BzQ6oJittJtMh/GpWmO5OXb7Z5r0KS5kiGmca+m7Lhsv7tjpA5LCVjIswKeukAj0wc4N444teNolo6JRKtWyPevA/ZC21/IRvPskI57I/qgub6rTqzQL/L6laWsn1ScM8NP0QMksCgYEA0SYTiqbkb4gg3JXgewxiOS1wvGm3pI5WUB2mmONWkh71+Phd52/F6tat9sZOlOt6HNAeGh0As7L2qOHEr3RX653lCKoVsdLLz9xk/iXsEX7a5ynYhwCzGyB+/ZrS73Gcd14M32QRH5iz1qyboMsim+7qpg6Jixu741zgUWxlrlMCgYAwRAIEDrZBvYZU36B6CYKPCds1DgvmfbrS9mhHK8nd15Dw9s2WHzHCm5KmjHPG8JDFYCXdF06H4mI6LKMAOH3HaSLj576pZPMwgX+9IraGeqbIpuAO8IJWdtgzE3jVeAZ0d9IQjzWvy0k6XZ1jqtoxdmsStv7OVO2vrUr1AT+yxwKBgQC/enypk+HPunFbglviS5QhhNiRprfaj/W1o2/CyV+Yp3/KUJioKkyok6LxKt0Gug8yEdd9UNAztMTeGNRZTcqwi/4D0LPL+ZFe3AgaJd3apqkCuezirGFNlJKu2/ACB6GYJyLh4ltK8iKzh5NzwoYM/M8W+Hg0Q0/g+EbmRLdSwQKBgQDT3Kp4XWXUTQsySQkfsvqoefI/C+hiUjhf1Xp1Y2mEuYHyfPXrxaD20Ao6L8y0Syx/aOTErj5V0jhAx/OHzajQttqQtWFEDx0NRxDmk0YVXeTceHyLvzEoziQXn4gX6tKGA8ohO9fTp4vKFwQJwGeoW6mRB5R2yI0JTFsHvRBObQ==" ], + "keyUse" : [ "SIG" ], + "certificate" : [ "MIICqzCCAZMCBgGVPfL13jANBgkqhkiG9w0BAQsFADAZMRcwFQYDVQQDDA5jZGstdGVzdC1yZWFsbTAeFw0yNTAyMjUxNjI5NDdaFw0zNTAyMjUxNjMxMjdaMBkxFzAVBgNVBAMMDmNkay10ZXN0LXJlYWxtMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAu0fLOomgHoAuBIuz1+jT6jij76az4U0o4xZRZRL+w9CrubLWVdXNyMrMtiRUgcqJU2/qV2CHi2NVRLLGvsBHOzvlFJ7vNSRH1r2GyzQwD3En2x3y/AB0L/e0qBl5irUYcPDG+4+VFqv9jms1E1pIvCsEDmw6EPUqM29C7oRCOEDw91EvxvE76VCKhEMdX/9FWe3b91bYXwyHolkpa+unlDmzO47AAtTpL6KJsggDyKXuI3+en7KcVdj/Vu19q2MaN8XVep2sUi5oYhcqS6e5+2bTyBN4u3PjKujx/AJEMzSWsVBP3Sb7zWq1LKNHeunKVkawP/ZZCHyK+L+FigZIUQIDAQABMA0GCSqGSIb3DQEBCwUAA4IBAQBWJkgfpCMyRZkvThzWs0mEFQ572cm1GIOZlva9UrwYgZvPIeCvaNw5s5Q6/ADjIE295nKAbeTOjnkxuIFkBRLe5HSP/czd39LuKkXjizP9hbB9xkaHLo83bqfPGS2Mbop5n5gp/sLwlpZDBGE4C5pm8aHZdaV8s+FL05OGgr7B7rqYHYs26yXhJA4zjtICoCcnU3xcB3REfCdYXf+7MmwSVMjMETW96xiPLHJdA1qDiwy9y7etdmV7Za/MeljdY+1elXCpNRjZ3GJiESPmvF8JO/kTuUU7UA2L8rDQedgDWoeiW0DkDA1oqMy/Dhb0HQTxuW+GKuERW6sY7+jOQvIv" ], + "priority" : [ "100" ] + } + }, { + "id" : "fe944a30-eef4-49a3-b1e7-82a0a8ca1822", + "name" : "aes-generated", + "providerId" : "aes-generated", + "subComponents" : { }, + "config" : { + "kid" : [ "ca8804c7-3bf5-406d-866e-6aacbba3fc94" ], + "secret" : [ "FUDfyi-KXgTZCKLuhhXPfA" ], + "priority" : [ "100" ] + } + }, { + "id" : "1afe86b0-0c6b-4427-a4b4-523026e022e3", + "name" : "rsa-enc-generated", + "providerId" : "rsa-enc-generated", + "subComponents" : { }, + "config" : { + "privateKey" : [ "MIIEpQIBAAKCAQEA8tYf9EEyMysT5TY7Z3sXpoGGPXWY1gTclRMtYk1PfjPZbzKmgSk8ihux+RGzuX3SRPCCju1XC0nRqi3HAOO3K5T/tIj/PX9/qw3MjJl6ivcYOUQMvjDIFmAG0dpJ+RM9nmCF6lMWB0G3ABik/yg0jf9nPwZ3rSsII7A+a+T4ZbBeznNJ1QWSmzqBRoc2a00jhg2PkQrKt6oFCGe0/SRcynbmu9RD0IuEsqn7uCXO9JRXSb2FohjfY3M2jqTgFlnl7UWKlChViSZAT5Z9iXpvr5ePmazqbKYSPjyeDHKiDkU1tLVO/jaooS+bQ1OfseHGpcRj7xZJNzQkz3JiQhyopwIDAQABAoIBAAadYPimuTCK1EHo79+28XZhqAx1VZjbpqT3UfcpiY7NTsQoQ9mk2LdSX2i12+8J+sS4YHYQO2mQgZuT7HKdWvH4kkNffMRqFePM8aBEb5tPDv12v+CDNHZ99/GWpqLLeWEFDa2YxP+kzjbvqB7wADtJLiczWYABrUXh/4rk2jdUc8r6oqq+GDPd+/mf19vsR3PG/7D8mpCdcbaUaVkFEJ/gpWOX99uNQjo4JIUYn7RnvPIHpYlhmSGbX0RJncE8LznSZDmE/owv3XO/dUW+ViUXJIE/W74OKZYRNYMMTw/dyH65zt0EP81e2SaCg7179b1ipVe/XAbCNGcdGQR71rECgYEA/KPrOi1BTBkQC3u96J+l3sbW1Jn8ZkIS7/la6FI8E1CFfdQ1FwqtRlZjn/yHj+3bUWY6FTXKDkX41W/FzM3NBDUi3npH7XjH4exMjZbjNwj5EmV7IRiOApKKqEh3Mk5oS4xuHW02emiaAuMEzy7rSbvKTx1PkOsnciw9ag6kJUsCgYEA9hDUdXHadAA3rPjCF9pRX+1/FK3SZnbZzYbbtZNUk8uOkWyeR0jy7M+wQStdUF+ncvbfrf7flCPjOU3Hx+eBSUENcTRf2hluBcOU0YvbUwEQaGIo7PnhKUckkb/SEFVz5KKVqhPLlAh69my+6yCEkQbUzqshGFvnw16s5D4EXJUCgYEAiBcotDsCjKKHkRgEMdYl/L5xpS6Z1t/K4bgZ70G1GNZsQl7YhmP6+QO6QMlAoRiI5u1BZoGGKXAp3ZeLHorR4G1bLisCqfpA+gNXLYJVPSU5Pl6qGCbpAJu9027Nvqyb4+5utq1JBmFobRurUIu+tQZSsZFhdfgmVItePc5LP8cCgYEAt8q0VpDW4aQz8c/Qca6Q73zeWdzHdd86Keib/RDxsH7vmzGtO4OCZ11twg+Y4GrCEP3S7wybhPQVSX0ORwNlLwkW+EJtgeNSqZ1/b9Dt3h7CWaVP+kleY82OxAqp5adeLB1AesvUWFrJNXzeUZN1UTnwA/oQezhScTwrGUQ1T80CgYEAhHbQv+q2mqmI2VI/4idH9vR3i9aBKUrM2YOjjKhB8kt07j3hHr58UWYvT5+MEvSk8aTj6Oj8XIN7dsHnDvJ+4jvvK4Bz2cASVPhFj+yBb9KJPB1iEh6pdO6JPR1GDpTa/TiS539c9iLF+/snrEXZ7f91+59mBSDewuGh3cu/Kkk=" ], + "keyUse" : [ "ENC" ], + "certificate" : [ "MIICqzCCAZMCBgGVPfL2NTANBgkqhkiG9w0BAQsFADAZMRcwFQYDVQQDDA5jZGstdGVzdC1yZWFsbTAeFw0yNTAyMjUxNjI5NDdaFw0zNTAyMjUxNjMxMjdaMBkxFzAVBgNVBAMMDmNkay10ZXN0LXJlYWxtMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA8tYf9EEyMysT5TY7Z3sXpoGGPXWY1gTclRMtYk1PfjPZbzKmgSk8ihux+RGzuX3SRPCCju1XC0nRqi3HAOO3K5T/tIj/PX9/qw3MjJl6ivcYOUQMvjDIFmAG0dpJ+RM9nmCF6lMWB0G3ABik/yg0jf9nPwZ3rSsII7A+a+T4ZbBeznNJ1QWSmzqBRoc2a00jhg2PkQrKt6oFCGe0/SRcynbmu9RD0IuEsqn7uCXO9JRXSb2FohjfY3M2jqTgFlnl7UWKlChViSZAT5Z9iXpvr5ePmazqbKYSPjyeDHKiDkU1tLVO/jaooS+bQ1OfseHGpcRj7xZJNzQkz3JiQhyopwIDAQABMA0GCSqGSIb3DQEBCwUAA4IBAQCh+63CKoldKib1t7OtymWCxvMiWRzfDd3Rj1iTno5ViGA3P6WWbN8jOQpdfM3BO5p2ZG29vtfH/8090evKROl8gR1zy6UPOaE/20yOlfFffPJJ6QfxueFiZe9s4UdcvY7UVLSyF/v/mu83yvvCE+53RH386k+y/xjEvQUMR4eR4sZlCQ05bc+wDDm4pZ4JWIbHTgni2birb38jXaNJklqE+ues7rhecME+F6Uz5mD3oGZtE4BxH3uVM7e2H0zzLXfe5Oa7IIk4BqU61Et5yHYgSfOolgOmMQ2gLzeTioeUWvyvcutEjgHHWIKGBMSCguX9kvwC7dxRQTTaRTNFKKm5" ], + "priority" : [ "100" ], + "algorithm" : [ "RSA-OAEP" ] + } + } ] + }, + "internationalizationEnabled" : false, + "supportedLocales" : [ ], + "authenticationFlows" : [ { + "id" : "2b1f3f9a-af81-49a2-b062-92929926b0f2", + "alias" : "Account verification options", + "description" : "Method with which to verity the existing account", + "providerId" : "basic-flow", + "topLevel" : false, + "builtIn" : true, + "authenticationExecutions" : [ { + "authenticator" : "idp-email-verification", + "authenticatorFlow" : false, + "requirement" : "ALTERNATIVE", + "priority" : 10, + "autheticatorFlow" : false, + "userSetupAllowed" : false + }, { + "authenticatorFlow" : true, + "requirement" : "ALTERNATIVE", + "priority" : 20, + "autheticatorFlow" : true, + "flowAlias" : "Verify Existing Account by Re-authentication", + "userSetupAllowed" : false + } ] + }, { + "id" : "f87548dd-66ee-4065-8669-c219c02da73b", + "alias" : "Browser - Conditional OTP", + "description" : "Flow to determine if the OTP is required for the authentication", + "providerId" : "basic-flow", + "topLevel" : false, + "builtIn" : true, + "authenticationExecutions" : [ { + "authenticator" : "conditional-user-configured", + "authenticatorFlow" : false, + "requirement" : "REQUIRED", + "priority" : 10, + "autheticatorFlow" : false, + "userSetupAllowed" : false + }, { + "authenticator" : "auth-otp-form", + "authenticatorFlow" : false, + "requirement" : "REQUIRED", + "priority" : 20, + "autheticatorFlow" : false, + "userSetupAllowed" : false + } ] + }, { + "id" : "6d7c77a0-c2f5-4ea1-8c7d-44d1b55fafa0", + "alias" : "Direct Grant - Conditional OTP", + "description" : "Flow to determine if the OTP is required for the authentication", + "providerId" : "basic-flow", + "topLevel" : false, + "builtIn" : true, + "authenticationExecutions" : [ { + "authenticator" : "conditional-user-configured", + "authenticatorFlow" : false, + "requirement" : "REQUIRED", + "priority" : 10, + "autheticatorFlow" : false, + "userSetupAllowed" : false + }, { + "authenticator" : "direct-grant-validate-otp", + "authenticatorFlow" : false, + "requirement" : "REQUIRED", + "priority" : 20, + "autheticatorFlow" : false, + "userSetupAllowed" : false + } ] + }, { + "id" : "36a10553-2dc6-4e6d-8597-238b9768099d", + "alias" : "First broker login - Conditional OTP", + "description" : "Flow to determine if the OTP is required for the authentication", + "providerId" : "basic-flow", + "topLevel" : false, + "builtIn" : true, + "authenticationExecutions" : [ { + "authenticator" : "conditional-user-configured", + "authenticatorFlow" : false, + "requirement" : "REQUIRED", + "priority" : 10, + "autheticatorFlow" : false, + "userSetupAllowed" : false + }, { + "authenticator" : "auth-otp-form", + "authenticatorFlow" : false, + "requirement" : "REQUIRED", + "priority" : 20, + "autheticatorFlow" : false, + "userSetupAllowed" : false + } ] + }, { + "id" : "344d9fdc-f191-4483-a27c-8df79be6a1cf", + "alias" : "Handle Existing Account", + "description" : "Handle what to do if there is existing account with same email/username like authenticated identity provider", + "providerId" : "basic-flow", + "topLevel" : false, + "builtIn" : true, + "authenticationExecutions" : [ { + "authenticator" : "idp-confirm-link", + "authenticatorFlow" : false, + "requirement" : "REQUIRED", + "priority" : 10, + "autheticatorFlow" : false, + "userSetupAllowed" : false + }, { + "authenticatorFlow" : true, + "requirement" : "REQUIRED", + "priority" : 20, + "autheticatorFlow" : true, + "flowAlias" : "Account verification options", + "userSetupAllowed" : false + } ] + }, { + "id" : "b800bab8-cd17-460e-8ebb-8e3964735a30", + "alias" : "Reset - Conditional OTP", + "description" : "Flow to determine if the OTP should be reset or not. Set to REQUIRED to force.", + "providerId" : "basic-flow", + "topLevel" : false, + "builtIn" : true, + "authenticationExecutions" : [ { + "authenticator" : "conditional-user-configured", + "authenticatorFlow" : false, + "requirement" : "REQUIRED", + "priority" : 10, + "autheticatorFlow" : false, + "userSetupAllowed" : false + }, { + "authenticator" : "reset-otp", + "authenticatorFlow" : false, + "requirement" : "REQUIRED", + "priority" : 20, + "autheticatorFlow" : false, + "userSetupAllowed" : false + } ] + }, { + "id" : "2df3c9b7-5bce-4d3e-87e1-a79b4a62b4de", + "alias" : "User creation or linking", + "description" : "Flow for the existing/non-existing user alternatives", + "providerId" : "basic-flow", + "topLevel" : false, + "builtIn" : true, + "authenticationExecutions" : [ { + "authenticatorConfig" : "create unique user config", + "authenticator" : "idp-create-user-if-unique", + "authenticatorFlow" : false, + "requirement" : "ALTERNATIVE", + "priority" : 10, + "autheticatorFlow" : false, + "userSetupAllowed" : false + }, { + "authenticatorFlow" : true, + "requirement" : "ALTERNATIVE", + "priority" : 20, + "autheticatorFlow" : true, + "flowAlias" : "Handle Existing Account", + "userSetupAllowed" : false + } ] + }, { + "id" : "f053a708-f95b-4218-9f93-e158bf39e418", + "alias" : "Verify Existing Account by Re-authentication", + "description" : "Reauthentication of existing account", + "providerId" : "basic-flow", + "topLevel" : false, + "builtIn" : true, + "authenticationExecutions" : [ { + "authenticator" : "idp-username-password-form", + "authenticatorFlow" : false, + "requirement" : "REQUIRED", + "priority" : 10, + "autheticatorFlow" : false, + "userSetupAllowed" : false + }, { + "authenticatorFlow" : true, + "requirement" : "CONDITIONAL", + "priority" : 20, + "autheticatorFlow" : true, + "flowAlias" : "First broker login - Conditional OTP", + "userSetupAllowed" : false + } ] + }, { + "id" : "33b4938d-4f6e-460d-98a2-d23882f1069a", + "alias" : "browser", + "description" : "browser based authentication", + "providerId" : "basic-flow", + "topLevel" : true, + "builtIn" : true, + "authenticationExecutions" : [ { + "authenticator" : "auth-cookie", + "authenticatorFlow" : false, + "requirement" : "ALTERNATIVE", + "priority" : 10, + "autheticatorFlow" : false, + "userSetupAllowed" : false + }, { + "authenticator" : "auth-spnego", + "authenticatorFlow" : false, + "requirement" : "DISABLED", + "priority" : 20, + "autheticatorFlow" : false, + "userSetupAllowed" : false + }, { + "authenticator" : "identity-provider-redirector", + "authenticatorFlow" : false, + "requirement" : "ALTERNATIVE", + "priority" : 25, + "autheticatorFlow" : false, + "userSetupAllowed" : false + }, { + "authenticatorFlow" : true, + "requirement" : "ALTERNATIVE", + "priority" : 30, + "autheticatorFlow" : true, + "flowAlias" : "forms", + "userSetupAllowed" : false + } ] + }, { + "id" : "e5379328-3061-4e3f-a3d8-97574e978fcf", + "alias" : "clients", + "description" : "Base authentication for clients", + "providerId" : "client-flow", + "topLevel" : true, + "builtIn" : true, + "authenticationExecutions" : [ { + "authenticator" : "client-secret", + "authenticatorFlow" : false, + "requirement" : "ALTERNATIVE", + "priority" : 10, + "autheticatorFlow" : false, + "userSetupAllowed" : false + }, { + "authenticator" : "client-jwt", + "authenticatorFlow" : false, + "requirement" : "ALTERNATIVE", + "priority" : 20, + "autheticatorFlow" : false, + "userSetupAllowed" : false + }, { + "authenticator" : "client-secret-jwt", + "authenticatorFlow" : false, + "requirement" : "ALTERNATIVE", + "priority" : 30, + "autheticatorFlow" : false, + "userSetupAllowed" : false + }, { + "authenticator" : "client-x509", + "authenticatorFlow" : false, + "requirement" : "ALTERNATIVE", + "priority" : 40, + "autheticatorFlow" : false, + "userSetupAllowed" : false + } ] + }, { + "id" : "58bf5c22-1e62-49c3-810c-f3b8735ff42a", + "alias" : "direct grant", + "description" : "OpenID Connect Resource Owner Grant", + "providerId" : "basic-flow", + "topLevel" : true, + "builtIn" : true, + "authenticationExecutions" : [ { + "authenticator" : "direct-grant-validate-username", + "authenticatorFlow" : false, + "requirement" : "REQUIRED", + "priority" : 10, + "autheticatorFlow" : false, + "userSetupAllowed" : false + }, { + "authenticator" : "direct-grant-validate-password", + "authenticatorFlow" : false, + "requirement" : "REQUIRED", + "priority" : 20, + "autheticatorFlow" : false, + "userSetupAllowed" : false + }, { + "authenticatorFlow" : true, + "requirement" : "CONDITIONAL", + "priority" : 30, + "autheticatorFlow" : true, + "flowAlias" : "Direct Grant - Conditional OTP", + "userSetupAllowed" : false + } ] + }, { + "id" : "f21f983f-970c-47b6-b905-1e6a903b5830", + "alias" : "docker auth", + "description" : "Used by Docker clients to authenticate against the IDP", + "providerId" : "basic-flow", + "topLevel" : true, + "builtIn" : true, + "authenticationExecutions" : [ { + "authenticator" : "docker-http-basic-authenticator", + "authenticatorFlow" : false, + "requirement" : "REQUIRED", + "priority" : 10, + "autheticatorFlow" : false, + "userSetupAllowed" : false + } ] + }, { + "id" : "ed3a3811-89ce-4457-8c06-9faeab2fb49d", + "alias" : "first broker login", + "description" : "Actions taken after first broker login with identity provider account, which is not yet linked to any Keycloak account", + "providerId" : "basic-flow", + "topLevel" : true, + "builtIn" : true, + "authenticationExecutions" : [ { + "authenticatorConfig" : "review profile config", + "authenticator" : "idp-review-profile", + "authenticatorFlow" : false, + "requirement" : "REQUIRED", + "priority" : 10, + "autheticatorFlow" : false, + "userSetupAllowed" : false + }, { + "authenticatorFlow" : true, + "requirement" : "REQUIRED", + "priority" : 20, + "autheticatorFlow" : true, + "flowAlias" : "User creation or linking", + "userSetupAllowed" : false + } ] + }, { + "id" : "0d26acef-121f-44ad-a5f3-b85bf4d1fc05", + "alias" : "forms", + "description" : "Username, password, otp and other auth forms.", + "providerId" : "basic-flow", + "topLevel" : false, + "builtIn" : true, + "authenticationExecutions" : [ { + "authenticator" : "auth-username-password-form", + "authenticatorFlow" : false, + "requirement" : "REQUIRED", + "priority" : 10, + "autheticatorFlow" : false, + "userSetupAllowed" : false + }, { + "authenticatorFlow" : true, + "requirement" : "CONDITIONAL", + "priority" : 20, + "autheticatorFlow" : true, + "flowAlias" : "Browser - Conditional OTP", + "userSetupAllowed" : false + } ] + }, { + "id" : "2c99b761-f61d-4423-8c9f-cb8fec5a9c00", + "alias" : "registration", + "description" : "registration flow", + "providerId" : "basic-flow", + "topLevel" : true, + "builtIn" : true, + "authenticationExecutions" : [ { + "authenticator" : "registration-page-form", + "authenticatorFlow" : true, + "requirement" : "REQUIRED", + "priority" : 10, + "autheticatorFlow" : true, + "flowAlias" : "registration form", + "userSetupAllowed" : false + } ] + }, { + "id" : "1ca08d0c-58f1-471c-90ac-2a24781a27cf", + "alias" : "registration form", + "description" : "registration form", + "providerId" : "form-flow", + "topLevel" : false, + "builtIn" : true, + "authenticationExecutions" : [ { + "authenticator" : "registration-user-creation", + "authenticatorFlow" : false, + "requirement" : "REQUIRED", + "priority" : 20, + "autheticatorFlow" : false, + "userSetupAllowed" : false + }, { + "authenticator" : "registration-password-action", + "authenticatorFlow" : false, + "requirement" : "REQUIRED", + "priority" : 50, + "autheticatorFlow" : false, + "userSetupAllowed" : false + }, { + "authenticator" : "registration-recaptcha-action", + "authenticatorFlow" : false, + "requirement" : "DISABLED", + "priority" : 60, + "autheticatorFlow" : false, + "userSetupAllowed" : false + }, { + "authenticator" : "registration-terms-and-conditions", + "authenticatorFlow" : false, + "requirement" : "DISABLED", + "priority" : 70, + "autheticatorFlow" : false, + "userSetupAllowed" : false + } ] + }, { + "id" : "69c9375d-f37d-4729-8d37-027c573b301e", + "alias" : "reset credentials", + "description" : "Reset credentials for a user if they forgot their password or something", + "providerId" : "basic-flow", + "topLevel" : true, + "builtIn" : true, + "authenticationExecutions" : [ { + "authenticator" : "reset-credentials-choose-user", + "authenticatorFlow" : false, + "requirement" : "REQUIRED", + "priority" : 10, + "autheticatorFlow" : false, + "userSetupAllowed" : false + }, { + "authenticator" : "reset-credential-email", + "authenticatorFlow" : false, + "requirement" : "REQUIRED", + "priority" : 20, + "autheticatorFlow" : false, + "userSetupAllowed" : false + }, { + "authenticator" : "reset-password", + "authenticatorFlow" : false, + "requirement" : "REQUIRED", + "priority" : 30, + "autheticatorFlow" : false, + "userSetupAllowed" : false + }, { + "authenticatorFlow" : true, + "requirement" : "CONDITIONAL", + "priority" : 40, + "autheticatorFlow" : true, + "flowAlias" : "Reset - Conditional OTP", + "userSetupAllowed" : false + } ] + }, { + "id" : "9319132c-d8c2-4c09-92ec-f539c1f303e3", + "alias" : "saml ecp", + "description" : "SAML ECP Profile Authentication Flow", + "providerId" : "basic-flow", + "topLevel" : true, + "builtIn" : true, + "authenticationExecutions" : [ { + "authenticator" : "http-basic-authenticator", + "authenticatorFlow" : false, + "requirement" : "REQUIRED", + "priority" : 10, + "autheticatorFlow" : false, + "userSetupAllowed" : false + } ] + } ], + "authenticatorConfig" : [ { + "id" : "cd3eef58-dca1-43e2-aac4-a20f508aa0f4", + "alias" : "create unique user config", + "config" : { + "require.password.update.after.registration" : "false" + } + }, { + "id" : "89a3a7e5-a8b5-4914-93bf-eae451e64507", + "alias" : "review profile config", + "config" : { + "update.profile.on.first.login" : "missing" + } + } ], + "requiredActions" : [ { + "alias" : "CONFIGURE_TOTP", + "name" : "Configure OTP", + "providerId" : "CONFIGURE_TOTP", + "enabled" : true, + "defaultAction" : false, + "priority" : 10, + "config" : { } + }, { + "alias" : "TERMS_AND_CONDITIONS", + "name" : "Terms and Conditions", + "providerId" : "TERMS_AND_CONDITIONS", + "enabled" : false, + "defaultAction" : false, + "priority" : 20, + "config" : { } + }, { + "alias" : "UPDATE_PASSWORD", + "name" : "Update Password", + "providerId" : "UPDATE_PASSWORD", + "enabled" : true, + "defaultAction" : false, + "priority" : 30, + "config" : { } + }, { + "alias" : "UPDATE_PROFILE", + "name" : "Update Profile", + "providerId" : "UPDATE_PROFILE", + "enabled" : true, + "defaultAction" : false, + "priority" : 40, + "config" : { } + }, { + "alias" : "VERIFY_EMAIL", + "name" : "Verify Email", + "providerId" : "VERIFY_EMAIL", + "enabled" : true, + "defaultAction" : false, + "priority" : 50, + "config" : { } + }, { + "alias" : "delete_account", + "name" : "Delete Account", + "providerId" : "delete_account", + "enabled" : false, + "defaultAction" : false, + "priority" : 60, + "config" : { } + }, { + "alias" : "webauthn-register", + "name" : "Webauthn Register", + "providerId" : "webauthn-register", + "enabled" : true, + "defaultAction" : false, + "priority" : 70, + "config" : { } + }, { + "alias" : "webauthn-register-passwordless", + "name" : "Webauthn Register Passwordless", + "providerId" : "webauthn-register-passwordless", + "enabled" : true, + "defaultAction" : false, + "priority" : 80, + "config" : { } + }, { + "alias" : "VERIFY_PROFILE", + "name" : "Verify Profile", + "providerId" : "VERIFY_PROFILE", + "enabled" : true, + "defaultAction" : false, + "priority" : 90, + "config" : { } + }, { + "alias" : "delete_credential", + "name" : "Delete Credential", + "providerId" : "delete_credential", + "enabled" : true, + "defaultAction" : false, + "priority" : 100, + "config" : { } + }, { + "alias" : "update_user_locale", + "name" : "Update User Locale", + "providerId" : "update_user_locale", + "enabled" : true, + "defaultAction" : false, + "priority" : 1000, + "config" : { } + } ], + "browserFlow" : "browser", + "registrationFlow" : "registration", + "directGrantFlow" : "direct grant", + "resetCredentialsFlow" : "reset credentials", + "clientAuthenticationFlow" : "clients", + "dockerAuthenticationFlow" : "docker auth", + "firstBrokerLoginFlow" : "first broker login", + "attributes" : { + "cibaBackchannelTokenDeliveryMode" : "poll", + "cibaExpiresIn" : "120", + "cibaAuthRequestedUserHint" : "login_hint", + "oauth2DeviceCodeLifespan" : "600", + "oauth2DevicePollingInterval" : "5", + "parRequestUriLifespan" : "60", + "cibaInterval" : "5", + "realmReusableOtpCode" : "false" + }, + "keycloakVersion" : "25.0.6", + "userManagedAccessAllowed" : false, + "organizationsEnabled" : false, + "clientProfiles" : { + "profiles" : [ ] + }, + "clientPolicies" : { + "policies" : [ ] + } +} \ No newline at end of file diff --git a/misc/keycloak/keycloak-export/cdk-test-realm-users-0.json b/misc/keycloak/keycloak-export/cdk-test-realm-users-0.json new file mode 100644 index 00000000..0398d792 --- /dev/null +++ b/misc/keycloak/keycloak-export/cdk-test-realm-users-0.json @@ -0,0 +1,27 @@ +{ + "realm" : "cdk-test-realm", + "users" : [ { + "id" : "6ea5cccc-bb0d-4757-a676-2515056fb4c6", + "username" : "cdk-test", + "firstName" : "test", + "lastName" : "test", + "email" : "test@email.com", + "emailVerified" : false, + "createdTimestamp" : 1740501176107, + "enabled" : true, + "totp" : false, + "credentials" : [ { + "id" : "a184d280-ee03-48c1-bd99-b60cbec7f828", + "type" : "password", + "userLabel" : "My password", + "createdDate" : 1740501197395, + "secretData" : "{\"value\":\"qIQdnJ76MfwoPE0NgGxjpNPOrWvlvnIhreaCA0fX88g=\",\"salt\":\"npddG2eT8Ofp/M2QORJu0Q==\",\"additionalParameters\":{}}", + "credentialData" : "{\"hashIterations\":5,\"algorithm\":\"argon2\",\"additionalParameters\":{\"hashLength\":[\"32\"],\"memory\":[\"7168\"],\"type\":[\"id\"],\"version\":[\"1.3\"],\"parallelism\":[\"1\"]}}" + } ], + "disableableCredentialTypes" : [ ], + "requiredActions" : [ ], + "realmRoles" : [ "default-roles-cdk-test-realm" ], + "notBefore" : 0, + "groups" : [ ] + } ] +} \ No newline at end of file diff --git a/misc/keycloak/keycloak-export/master-realm.json b/misc/keycloak/keycloak-export/master-realm.json new file mode 100644 index 00000000..f1309152 --- /dev/null +++ b/misc/keycloak/keycloak-export/master-realm.json @@ -0,0 +1,2050 @@ +{ + "id" : "1d24ffca-4ac4-4768-b0ea-0e903339234f", + "realm" : "master", + "displayName" : "Keycloak", + "displayNameHtml" : "
Keycloak
", + "notBefore" : 0, + "defaultSignatureAlgorithm" : "RS256", + "revokeRefreshToken" : false, + "refreshTokenMaxReuse" : 0, + "accessTokenLifespan" : 60, + "accessTokenLifespanForImplicitFlow" : 900, + "ssoSessionIdleTimeout" : 1800, + "ssoSessionMaxLifespan" : 36000, + "ssoSessionIdleTimeoutRememberMe" : 0, + "ssoSessionMaxLifespanRememberMe" : 0, + "offlineSessionIdleTimeout" : 2592000, + "offlineSessionMaxLifespanEnabled" : false, + "offlineSessionMaxLifespan" : 5184000, + "clientSessionIdleTimeout" : 0, + "clientSessionMaxLifespan" : 0, + "clientOfflineSessionIdleTimeout" : 0, + "clientOfflineSessionMaxLifespan" : 0, + "accessCodeLifespan" : 60, + "accessCodeLifespanUserAction" : 300, + "accessCodeLifespanLogin" : 1800, + "actionTokenGeneratedByAdminLifespan" : 43200, + "actionTokenGeneratedByUserLifespan" : 300, + "oauth2DeviceCodeLifespan" : 600, + "oauth2DevicePollingInterval" : 5, + "enabled" : true, + "sslRequired" : "external", + "registrationAllowed" : false, + "registrationEmailAsUsername" : false, + "rememberMe" : false, + "verifyEmail" : false, + "loginWithEmailAllowed" : true, + "duplicateEmailsAllowed" : false, + "resetPasswordAllowed" : false, + "editUsernameAllowed" : false, + "bruteForceProtected" : false, + "permanentLockout" : false, + "maxTemporaryLockouts" : 0, + "maxFailureWaitSeconds" : 900, + "minimumQuickLoginWaitSeconds" : 60, + "waitIncrementSeconds" : 60, + "quickLoginCheckMilliSeconds" : 1000, + "maxDeltaTimeSeconds" : 43200, + "failureFactor" : 30, + "roles" : { + "realm" : [ { + "id" : "ba93d0e0-2dba-4b26-b4ce-0580c8d69ef3", + "name" : "create-realm", + "description" : "${role_create-realm}", + "composite" : false, + "clientRole" : false, + "containerId" : "1d24ffca-4ac4-4768-b0ea-0e903339234f", + "attributes" : { } + }, { + "id" : "2f5c8fa3-423d-4629-8f56-0ecb7d3b1e54", + "name" : "default-roles-master", + "description" : "${role_default-roles}", + "composite" : true, + "composites" : { + "realm" : [ "offline_access", "uma_authorization" ], + "client" : { + "account" : [ "manage-account", "view-profile" ] + } + }, + "clientRole" : false, + "containerId" : "1d24ffca-4ac4-4768-b0ea-0e903339234f", + "attributes" : { } + }, { + "id" : "cd9f8abb-2660-45e8-a324-8e8a7a3c8747", + "name" : "offline_access", + "description" : "${role_offline-access}", + "composite" : false, + "clientRole" : false, + "containerId" : "1d24ffca-4ac4-4768-b0ea-0e903339234f", + "attributes" : { } + }, { + "id" : "38fc9800-640b-435a-bf3c-0e6e2c366419", + "name" : "uma_authorization", + "description" : "${role_uma_authorization}", + "composite" : false, + "clientRole" : false, + "containerId" : "1d24ffca-4ac4-4768-b0ea-0e903339234f", + "attributes" : { } + }, { + "id" : "4113269c-9ee1-4b5d-870b-c60c57207c1a", + "name" : "admin", + "description" : "${role_admin}", + "composite" : true, + "composites" : { + "realm" : [ "create-realm" ], + "client" : { + "cdk-test-realm-realm" : [ "view-identity-providers", "create-client", "manage-authorization", "manage-identity-providers", "impersonation", "view-users", "query-clients", "query-groups", "manage-realm", "query-users", "manage-users", "view-events", "manage-clients", "view-clients", "view-authorization", "view-realm", "query-realms", "manage-events" ], + "master-realm" : [ "view-events", "manage-identity-providers", "manage-users", "query-users", "manage-clients", "query-groups", "view-authorization", "impersonation", "view-identity-providers", "manage-events", "manage-authorization", "create-client", "query-realms", "query-clients", "view-realm", "view-users", "view-clients", "manage-realm" ] + } + }, + "clientRole" : false, + "containerId" : "1d24ffca-4ac4-4768-b0ea-0e903339234f", + "attributes" : { } + } ], + "client" : { + "security-admin-console" : [ ], + "admin-cli" : [ ], + "cdk-test-realm-realm" : [ { + "id" : "aa223cb1-e99d-437d-80f6-2e66df9c54a8", + "name" : "manage-realm", + "description" : "${role_manage-realm}", + "composite" : false, + "clientRole" : true, + "containerId" : "53590271-2c57-4afd-90c5-6eafb383d294", + "attributes" : { } + }, { + "id" : "2f2de2f0-5581-4123-ba19-e1c6aafb6d4d", + "name" : "view-identity-providers", + "description" : "${role_view-identity-providers}", + "composite" : false, + "clientRole" : true, + "containerId" : "53590271-2c57-4afd-90c5-6eafb383d294", + "attributes" : { } + }, { + "id" : "10643e2c-5cf5-4cea-ae57-9036166671a3", + "name" : "query-users", + "description" : "${role_query-users}", + "composite" : false, + "clientRole" : true, + "containerId" : "53590271-2c57-4afd-90c5-6eafb383d294", + "attributes" : { } + }, { + "id" : "0952d2dc-9613-4bd8-9410-6ff0f41c70a2", + "name" : "manage-users", + "description" : "${role_manage-users}", + "composite" : false, + "clientRole" : true, + "containerId" : "53590271-2c57-4afd-90c5-6eafb383d294", + "attributes" : { } + }, { + "id" : "669aac9d-6187-48e5-aea5-58323127a5ca", + "name" : "create-client", + "description" : "${role_create-client}", + "composite" : false, + "clientRole" : true, + "containerId" : "53590271-2c57-4afd-90c5-6eafb383d294", + "attributes" : { } + }, { + "id" : "b2c66a87-e9e9-409e-ad23-ac1a8d00b543", + "name" : "manage-clients", + "description" : "${role_manage-clients}", + "composite" : false, + "clientRole" : true, + "containerId" : "53590271-2c57-4afd-90c5-6eafb383d294", + "attributes" : { } + }, { + "id" : "0ce89f0a-9304-444e-a3a6-69d6951cbed7", + "name" : "view-events", + "description" : "${role_view-events}", + "composite" : false, + "clientRole" : true, + "containerId" : "53590271-2c57-4afd-90c5-6eafb383d294", + "attributes" : { } + }, { + "id" : "efd60554-72eb-470f-a522-fe0c20862bb0", + "name" : "manage-authorization", + "description" : "${role_manage-authorization}", + "composite" : false, + "clientRole" : true, + "containerId" : "53590271-2c57-4afd-90c5-6eafb383d294", + "attributes" : { } + }, { + "id" : "5312b863-afc8-414f-930a-454e1c8396a6", + "name" : "view-authorization", + "description" : "${role_view-authorization}", + "composite" : false, + "clientRole" : true, + "containerId" : "53590271-2c57-4afd-90c5-6eafb383d294", + "attributes" : { } + }, { + "id" : "3c48368a-392a-4b7f-b990-1b893fb8fe8e", + "name" : "view-clients", + "description" : "${role_view-clients}", + "composite" : true, + "composites" : { + "client" : { + "cdk-test-realm-realm" : [ "query-clients" ] + } + }, + "clientRole" : true, + "containerId" : "53590271-2c57-4afd-90c5-6eafb383d294", + "attributes" : { } + }, { + "id" : "59fcdb27-c8bc-4750-9247-3cd75133cc3a", + "name" : "view-realm", + "description" : "${role_view-realm}", + "composite" : false, + "clientRole" : true, + "containerId" : "53590271-2c57-4afd-90c5-6eafb383d294", + "attributes" : { } + }, { + "id" : "e183c19f-facd-4a01-8c2f-10de5f934efb", + "name" : "manage-identity-providers", + "description" : "${role_manage-identity-providers}", + "composite" : false, + "clientRole" : true, + "containerId" : "53590271-2c57-4afd-90c5-6eafb383d294", + "attributes" : { } + }, { + "id" : "071401b4-5882-439e-a654-7b898a460352", + "name" : "query-realms", + "description" : "${role_query-realms}", + "composite" : false, + "clientRole" : true, + "containerId" : "53590271-2c57-4afd-90c5-6eafb383d294", + "attributes" : { } + }, { + "id" : "5edc72d0-f70e-41b0-931f-74b69ed0032e", + "name" : "manage-events", + "description" : "${role_manage-events}", + "composite" : false, + "clientRole" : true, + "containerId" : "53590271-2c57-4afd-90c5-6eafb383d294", + "attributes" : { } + }, { + "id" : "bd0ee685-0820-4702-ab07-67aa26d8c885", + "name" : "impersonation", + "description" : "${role_impersonation}", + "composite" : false, + "clientRole" : true, + "containerId" : "53590271-2c57-4afd-90c5-6eafb383d294", + "attributes" : { } + }, { + "id" : "76d31321-6672-4891-a323-fe9b49c6b84d", + "name" : "view-users", + "description" : "${role_view-users}", + "composite" : true, + "composites" : { + "client" : { + "cdk-test-realm-realm" : [ "query-users", "query-groups" ] + } + }, + "clientRole" : true, + "containerId" : "53590271-2c57-4afd-90c5-6eafb383d294", + "attributes" : { } + }, { + "id" : "32bd2047-c722-408d-8de8-93521f26e7e2", + "name" : "query-clients", + "description" : "${role_query-clients}", + "composite" : false, + "clientRole" : true, + "containerId" : "53590271-2c57-4afd-90c5-6eafb383d294", + "attributes" : { } + }, { + "id" : "6bb7b7df-a87d-4c11-81f9-1e9c75aca185", + "name" : "query-groups", + "description" : "${role_query-groups}", + "composite" : false, + "clientRole" : true, + "containerId" : "53590271-2c57-4afd-90c5-6eafb383d294", + "attributes" : { } + } ], + "account-console" : [ ], + "broker" : [ { + "id" : "d81ef4f7-fad7-4c88-a554-1e1557df407a", + "name" : "read-token", + "description" : "${role_read-token}", + "composite" : false, + "clientRole" : true, + "containerId" : "cfc4bfe2-8344-420b-9aac-36a6f1885f0a", + "attributes" : { } + } ], + "cashu-client" : [ ], + "master-realm" : [ { + "id" : "1f29e846-52e5-456b-b92c-a18ea91a2431", + "name" : "manage-authorization", + "description" : "${role_manage-authorization}", + "composite" : false, + "clientRole" : true, + "containerId" : "37feeea0-d97b-4b09-826b-d5fc83b40f90", + "attributes" : { } + }, { + "id" : "c28af044-2b2f-4fd2-bf30-d4162d90e0ca", + "name" : "manage-identity-providers", + "description" : "${role_manage-identity-providers}", + "composite" : false, + "clientRole" : true, + "containerId" : "37feeea0-d97b-4b09-826b-d5fc83b40f90", + "attributes" : { } + }, { + "id" : "786a9ebe-01f4-4425-87e1-a86cc48436f6", + "name" : "view-events", + "description" : "${role_view-events}", + "composite" : false, + "clientRole" : true, + "containerId" : "37feeea0-d97b-4b09-826b-d5fc83b40f90", + "attributes" : { } + }, { + "id" : "51e09cb3-fa17-4808-9a8a-6aae5c70852e", + "name" : "manage-users", + "description" : "${role_manage-users}", + "composite" : false, + "clientRole" : true, + "containerId" : "37feeea0-d97b-4b09-826b-d5fc83b40f90", + "attributes" : { } + }, { + "id" : "7832307c-d9b6-412e-bd2e-b6dc4d29218f", + "name" : "query-users", + "description" : "${role_query-users}", + "composite" : false, + "clientRole" : true, + "containerId" : "37feeea0-d97b-4b09-826b-d5fc83b40f90", + "attributes" : { } + }, { + "id" : "62e8af8e-2d3f-4c4c-9713-2a3900f70f70", + "name" : "create-client", + "description" : "${role_create-client}", + "composite" : false, + "clientRole" : true, + "containerId" : "37feeea0-d97b-4b09-826b-d5fc83b40f90", + "attributes" : { } + }, { + "id" : "20228da6-3ec4-4a53-89a4-6e66a093a609", + "name" : "query-realms", + "description" : "${role_query-realms}", + "composite" : false, + "clientRole" : true, + "containerId" : "37feeea0-d97b-4b09-826b-d5fc83b40f90", + "attributes" : { } + }, { + "id" : "14c87db1-21a8-479f-b197-bdb5a2edc870", + "name" : "query-clients", + "description" : "${role_query-clients}", + "composite" : false, + "clientRole" : true, + "containerId" : "37feeea0-d97b-4b09-826b-d5fc83b40f90", + "attributes" : { } + }, { + "id" : "3cf2521e-81a7-4d9c-95ac-56de1dd53bac", + "name" : "manage-clients", + "description" : "${role_manage-clients}", + "composite" : false, + "clientRole" : true, + "containerId" : "37feeea0-d97b-4b09-826b-d5fc83b40f90", + "attributes" : { } + }, { + "id" : "7d86de80-ad92-4dd0-9fc6-347d09dfabd4", + "name" : "query-groups", + "description" : "${role_query-groups}", + "composite" : false, + "clientRole" : true, + "containerId" : "37feeea0-d97b-4b09-826b-d5fc83b40f90", + "attributes" : { } + }, { + "id" : "9da4107a-373e-4a56-a01b-c8176425f791", + "name" : "view-clients", + "description" : "${role_view-clients}", + "composite" : true, + "composites" : { + "client" : { + "master-realm" : [ "query-clients" ] + } + }, + "clientRole" : true, + "containerId" : "37feeea0-d97b-4b09-826b-d5fc83b40f90", + "attributes" : { } + }, { + "id" : "8b4ea93c-f15e-49bc-9028-71be0c6ad5e5", + "name" : "view-realm", + "description" : "${role_view-realm}", + "composite" : false, + "clientRole" : true, + "containerId" : "37feeea0-d97b-4b09-826b-d5fc83b40f90", + "attributes" : { } + }, { + "id" : "9cd7c487-d13f-46ae-bee9-d1be1dc26793", + "name" : "view-users", + "description" : "${role_view-users}", + "composite" : true, + "composites" : { + "client" : { + "master-realm" : [ "query-users", "query-groups" ] + } + }, + "clientRole" : true, + "containerId" : "37feeea0-d97b-4b09-826b-d5fc83b40f90", + "attributes" : { } + }, { + "id" : "ce09ee0a-f11f-4c0b-925d-42f58ea7fbe8", + "name" : "view-authorization", + "description" : "${role_view-authorization}", + "composite" : false, + "clientRole" : true, + "containerId" : "37feeea0-d97b-4b09-826b-d5fc83b40f90", + "attributes" : { } + }, { + "id" : "2e216db9-47db-4531-b1bb-0046ea3b1a5d", + "name" : "impersonation", + "description" : "${role_impersonation}", + "composite" : false, + "clientRole" : true, + "containerId" : "37feeea0-d97b-4b09-826b-d5fc83b40f90", + "attributes" : { } + }, { + "id" : "f576a77b-f195-49c7-9a01-9a40fd156ddc", + "name" : "view-identity-providers", + "description" : "${role_view-identity-providers}", + "composite" : false, + "clientRole" : true, + "containerId" : "37feeea0-d97b-4b09-826b-d5fc83b40f90", + "attributes" : { } + }, { + "id" : "9c397d9d-12d2-4e50-9a54-153cf445120a", + "name" : "manage-realm", + "description" : "${role_manage-realm}", + "composite" : false, + "clientRole" : true, + "containerId" : "37feeea0-d97b-4b09-826b-d5fc83b40f90", + "attributes" : { } + }, { + "id" : "0f4e14a6-c645-4de5-93cb-b4014d723018", + "name" : "manage-events", + "description" : "${role_manage-events}", + "composite" : false, + "clientRole" : true, + "containerId" : "37feeea0-d97b-4b09-826b-d5fc83b40f90", + "attributes" : { } + } ], + "account" : [ { + "id" : "ed7aeb74-0fbe-431a-af3e-8cf4e57c0d31", + "name" : "delete-account", + "description" : "${role_delete-account}", + "composite" : false, + "clientRole" : true, + "containerId" : "c55c5e95-1dc4-4839-a3a9-69131ebf2d6b", + "attributes" : { } + }, { + "id" : "c4e4ef21-b25e-4eb0-a152-9dfe7fb4ae36", + "name" : "view-applications", + "description" : "${role_view-applications}", + "composite" : false, + "clientRole" : true, + "containerId" : "c55c5e95-1dc4-4839-a3a9-69131ebf2d6b", + "attributes" : { } + }, { + "id" : "dab80d57-efe9-4cbe-b4f1-2917a514858e", + "name" : "view-consent", + "description" : "${role_view-consent}", + "composite" : false, + "clientRole" : true, + "containerId" : "c55c5e95-1dc4-4839-a3a9-69131ebf2d6b", + "attributes" : { } + }, { + "id" : "ae3af69d-5311-49ac-a5f1-d51da546acc5", + "name" : "manage-account", + "description" : "${role_manage-account}", + "composite" : true, + "composites" : { + "client" : { + "account" : [ "manage-account-links" ] + } + }, + "clientRole" : true, + "containerId" : "c55c5e95-1dc4-4839-a3a9-69131ebf2d6b", + "attributes" : { } + }, { + "id" : "ae9a79a6-a289-4d9f-a6ec-b365e3efe16c", + "name" : "manage-account-links", + "description" : "${role_manage-account-links}", + "composite" : false, + "clientRole" : true, + "containerId" : "c55c5e95-1dc4-4839-a3a9-69131ebf2d6b", + "attributes" : { } + }, { + "id" : "fa7865bb-42f4-486a-bcf1-f926da94baa1", + "name" : "manage-consent", + "description" : "${role_manage-consent}", + "composite" : true, + "composites" : { + "client" : { + "account" : [ "view-consent" ] + } + }, + "clientRole" : true, + "containerId" : "c55c5e95-1dc4-4839-a3a9-69131ebf2d6b", + "attributes" : { } + }, { + "id" : "fa5e569e-9395-4e2b-9323-1a7ac9988e64", + "name" : "view-groups", + "description" : "${role_view-groups}", + "composite" : false, + "clientRole" : true, + "containerId" : "c55c5e95-1dc4-4839-a3a9-69131ebf2d6b", + "attributes" : { } + }, { + "id" : "15297ce1-a7d8-4d8d-b8d0-607f732c949a", + "name" : "view-profile", + "description" : "${role_view-profile}", + "composite" : false, + "clientRole" : true, + "containerId" : "c55c5e95-1dc4-4839-a3a9-69131ebf2d6b", + "attributes" : { } + } ] + } + }, + "groups" : [ ], + "defaultRole" : { + "id" : "2f5c8fa3-423d-4629-8f56-0ecb7d3b1e54", + "name" : "default-roles-master", + "description" : "${role_default-roles}", + "composite" : true, + "clientRole" : false, + "containerId" : "1d24ffca-4ac4-4768-b0ea-0e903339234f" + }, + "requiredCredentials" : [ "password" ], + "otpPolicyType" : "totp", + "otpPolicyAlgorithm" : "HmacSHA1", + "otpPolicyInitialCounter" : 0, + "otpPolicyDigits" : 6, + "otpPolicyLookAheadWindow" : 1, + "otpPolicyPeriod" : 30, + "otpPolicyCodeReusable" : false, + "otpSupportedApplications" : [ "totpAppFreeOTPName", "totpAppGoogleName", "totpAppMicrosoftAuthenticatorName" ], + "localizationTexts" : { }, + "webAuthnPolicyRpEntityName" : "keycloak", + "webAuthnPolicySignatureAlgorithms" : [ "ES256" ], + "webAuthnPolicyRpId" : "", + "webAuthnPolicyAttestationConveyancePreference" : "not specified", + "webAuthnPolicyAuthenticatorAttachment" : "not specified", + "webAuthnPolicyRequireResidentKey" : "not specified", + "webAuthnPolicyUserVerificationRequirement" : "not specified", + "webAuthnPolicyCreateTimeout" : 0, + "webAuthnPolicyAvoidSameAuthenticatorRegister" : false, + "webAuthnPolicyAcceptableAaguids" : [ ], + "webAuthnPolicyExtraOrigins" : [ ], + "webAuthnPolicyPasswordlessRpEntityName" : "keycloak", + "webAuthnPolicyPasswordlessSignatureAlgorithms" : [ "ES256" ], + "webAuthnPolicyPasswordlessRpId" : "", + "webAuthnPolicyPasswordlessAttestationConveyancePreference" : "not specified", + "webAuthnPolicyPasswordlessAuthenticatorAttachment" : "not specified", + "webAuthnPolicyPasswordlessRequireResidentKey" : "not specified", + "webAuthnPolicyPasswordlessUserVerificationRequirement" : "not specified", + "webAuthnPolicyPasswordlessCreateTimeout" : 0, + "webAuthnPolicyPasswordlessAvoidSameAuthenticatorRegister" : false, + "webAuthnPolicyPasswordlessAcceptableAaguids" : [ ], + "webAuthnPolicyPasswordlessExtraOrigins" : [ ], + "scopeMappings" : [ { + "clientScope" : "offline_access", + "roles" : [ "offline_access" ] + } ], + "clientScopeMappings" : { + "account" : [ { + "client" : "account-console", + "roles" : [ "manage-account", "view-groups" ] + } ] + }, + "clients" : [ { + "id" : "c55c5e95-1dc4-4839-a3a9-69131ebf2d6b", + "clientId" : "account", + "name" : "${client_account}", + "rootUrl" : "${authBaseUrl}", + "baseUrl" : "/realms/master/account/", + "surrogateAuthRequired" : false, + "enabled" : true, + "alwaysDisplayInConsole" : false, + "clientAuthenticatorType" : "client-secret", + "redirectUris" : [ "/realms/master/account/*" ], + "webOrigins" : [ ], + "notBefore" : 0, + "bearerOnly" : false, + "consentRequired" : false, + "standardFlowEnabled" : true, + "implicitFlowEnabled" : false, + "directAccessGrantsEnabled" : false, + "serviceAccountsEnabled" : false, + "publicClient" : true, + "frontchannelLogout" : false, + "protocol" : "openid-connect", + "attributes" : { + "post.logout.redirect.uris" : "+" + }, + "authenticationFlowBindingOverrides" : { }, + "fullScopeAllowed" : false, + "nodeReRegistrationTimeout" : 0, + "defaultClientScopes" : [ "web-origins", "acr", "roles", "profile", "basic", "email" ], + "optionalClientScopes" : [ "address", "phone", "offline_access", "microprofile-jwt" ] + }, { + "id" : "ef7a517a-ecd4-4e99-a8f2-ad82887e6eaf", + "clientId" : "account-console", + "name" : "${client_account-console}", + "rootUrl" : "${authBaseUrl}", + "baseUrl" : "/realms/master/account/", + "surrogateAuthRequired" : false, + "enabled" : true, + "alwaysDisplayInConsole" : false, + "clientAuthenticatorType" : "client-secret", + "redirectUris" : [ "/realms/master/account/*" ], + "webOrigins" : [ ], + "notBefore" : 0, + "bearerOnly" : false, + "consentRequired" : false, + "standardFlowEnabled" : true, + "implicitFlowEnabled" : false, + "directAccessGrantsEnabled" : false, + "serviceAccountsEnabled" : false, + "publicClient" : true, + "frontchannelLogout" : false, + "protocol" : "openid-connect", + "attributes" : { + "post.logout.redirect.uris" : "+", + "pkce.code.challenge.method" : "S256" + }, + "authenticationFlowBindingOverrides" : { }, + "fullScopeAllowed" : false, + "nodeReRegistrationTimeout" : 0, + "protocolMappers" : [ { + "id" : "383e5ae9-03c8-45eb-a659-55c6125676ed", + "name" : "audience resolve", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-audience-resolve-mapper", + "consentRequired" : false, + "config" : { } + } ], + "defaultClientScopes" : [ "web-origins", "acr", "roles", "profile", "basic", "email" ], + "optionalClientScopes" : [ "address", "phone", "offline_access", "microprofile-jwt" ] + }, { + "id" : "8deb794f-9bd9-4e41-874d-5d95f9688d3d", + "clientId" : "admin-cli", + "name" : "${client_admin-cli}", + "surrogateAuthRequired" : false, + "enabled" : true, + "alwaysDisplayInConsole" : false, + "clientAuthenticatorType" : "client-secret", + "redirectUris" : [ ], + "webOrigins" : [ ], + "notBefore" : 0, + "bearerOnly" : false, + "consentRequired" : false, + "standardFlowEnabled" : false, + "implicitFlowEnabled" : false, + "directAccessGrantsEnabled" : true, + "serviceAccountsEnabled" : false, + "publicClient" : true, + "frontchannelLogout" : false, + "protocol" : "openid-connect", + "attributes" : { }, + "authenticationFlowBindingOverrides" : { }, + "fullScopeAllowed" : false, + "nodeReRegistrationTimeout" : 0, + "defaultClientScopes" : [ "web-origins", "acr", "roles", "profile", "basic", "email" ], + "optionalClientScopes" : [ "address", "phone", "offline_access", "microprofile-jwt" ] + }, { + "id" : "cfc4bfe2-8344-420b-9aac-36a6f1885f0a", + "clientId" : "broker", + "name" : "${client_broker}", + "surrogateAuthRequired" : false, + "enabled" : true, + "alwaysDisplayInConsole" : false, + "clientAuthenticatorType" : "client-secret", + "redirectUris" : [ ], + "webOrigins" : [ ], + "notBefore" : 0, + "bearerOnly" : true, + "consentRequired" : false, + "standardFlowEnabled" : true, + "implicitFlowEnabled" : false, + "directAccessGrantsEnabled" : false, + "serviceAccountsEnabled" : false, + "publicClient" : false, + "frontchannelLogout" : false, + "protocol" : "openid-connect", + "attributes" : { }, + "authenticationFlowBindingOverrides" : { }, + "fullScopeAllowed" : false, + "nodeReRegistrationTimeout" : 0, + "defaultClientScopes" : [ "web-origins", "acr", "roles", "profile", "basic", "email" ], + "optionalClientScopes" : [ "address", "phone", "offline_access", "microprofile-jwt" ] + }, { + "id" : "481ebf32-0c1f-4fcc-b164-f5f5ca4bdedf", + "clientId" : "cashu-client", + "name" : "", + "description" : "", + "rootUrl" : "", + "adminUrl" : "", + "baseUrl" : "", + "surrogateAuthRequired" : false, + "enabled" : true, + "alwaysDisplayInConsole" : false, + "clientAuthenticatorType" : "client-secret", + "redirectUris" : [ "/*" ], + "webOrigins" : [ "/*" ], + "notBefore" : 0, + "bearerOnly" : false, + "consentRequired" : false, + "standardFlowEnabled" : true, + "implicitFlowEnabled" : true, + "directAccessGrantsEnabled" : true, + "serviceAccountsEnabled" : false, + "publicClient" : true, + "frontchannelLogout" : true, + "protocol" : "openid-connect", + "attributes" : { + "oidc.ciba.grant.enabled" : "false", + "backchannel.logout.session.required" : "true", + "oauth2.device.authorization.grant.enabled" : "true", + "backchannel.logout.revoke.offline.tokens" : "false" + }, + "authenticationFlowBindingOverrides" : { }, + "fullScopeAllowed" : true, + "nodeReRegistrationTimeout" : -1, + "defaultClientScopes" : [ "web-origins", "acr", "roles", "profile", "basic", "email" ], + "optionalClientScopes" : [ "address", "phone", "offline_access", "microprofile-jwt" ] + }, { + "id" : "53590271-2c57-4afd-90c5-6eafb383d294", + "clientId" : "cdk-test-realm-realm", + "name" : "cdk-test-realm Realm", + "surrogateAuthRequired" : false, + "enabled" : true, + "alwaysDisplayInConsole" : false, + "clientAuthenticatorType" : "client-secret", + "redirectUris" : [ ], + "webOrigins" : [ ], + "notBefore" : 0, + "bearerOnly" : true, + "consentRequired" : false, + "standardFlowEnabled" : true, + "implicitFlowEnabled" : false, + "directAccessGrantsEnabled" : false, + "serviceAccountsEnabled" : false, + "publicClient" : false, + "frontchannelLogout" : false, + "attributes" : { }, + "authenticationFlowBindingOverrides" : { }, + "fullScopeAllowed" : false, + "nodeReRegistrationTimeout" : 0, + "defaultClientScopes" : [ ], + "optionalClientScopes" : [ ] + }, { + "id" : "37feeea0-d97b-4b09-826b-d5fc83b40f90", + "clientId" : "master-realm", + "name" : "master Realm", + "surrogateAuthRequired" : false, + "enabled" : true, + "alwaysDisplayInConsole" : false, + "clientAuthenticatorType" : "client-secret", + "redirectUris" : [ ], + "webOrigins" : [ ], + "notBefore" : 0, + "bearerOnly" : true, + "consentRequired" : false, + "standardFlowEnabled" : true, + "implicitFlowEnabled" : false, + "directAccessGrantsEnabled" : false, + "serviceAccountsEnabled" : false, + "publicClient" : false, + "frontchannelLogout" : false, + "attributes" : { }, + "authenticationFlowBindingOverrides" : { }, + "fullScopeAllowed" : false, + "nodeReRegistrationTimeout" : 0, + "defaultClientScopes" : [ "web-origins", "acr", "roles", "profile", "basic", "email" ], + "optionalClientScopes" : [ "address", "phone", "offline_access", "microprofile-jwt" ] + }, { + "id" : "e3c6f9ce-7bff-47b3-807a-5a84a7d2078f", + "clientId" : "security-admin-console", + "name" : "${client_security-admin-console}", + "rootUrl" : "${authAdminUrl}", + "baseUrl" : "/admin/master/console/", + "surrogateAuthRequired" : false, + "enabled" : true, + "alwaysDisplayInConsole" : false, + "clientAuthenticatorType" : "client-secret", + "redirectUris" : [ "/admin/master/console/*" ], + "webOrigins" : [ "+" ], + "notBefore" : 0, + "bearerOnly" : false, + "consentRequired" : false, + "standardFlowEnabled" : true, + "implicitFlowEnabled" : false, + "directAccessGrantsEnabled" : false, + "serviceAccountsEnabled" : false, + "publicClient" : true, + "frontchannelLogout" : false, + "protocol" : "openid-connect", + "attributes" : { + "post.logout.redirect.uris" : "+", + "pkce.code.challenge.method" : "S256" + }, + "authenticationFlowBindingOverrides" : { }, + "fullScopeAllowed" : false, + "nodeReRegistrationTimeout" : 0, + "protocolMappers" : [ { + "id" : "e56848d1-548c-4b65-b716-b8f5a921caf0", + "name" : "locale", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usermodel-attribute-mapper", + "consentRequired" : false, + "config" : { + "introspection.token.claim" : "true", + "userinfo.token.claim" : "true", + "user.attribute" : "locale", + "id.token.claim" : "true", + "access.token.claim" : "true", + "claim.name" : "locale", + "jsonType.label" : "String" + } + } ], + "defaultClientScopes" : [ "web-origins", "acr", "roles", "profile", "basic", "email" ], + "optionalClientScopes" : [ "address", "phone", "offline_access", "microprofile-jwt" ] + } ], + "clientScopes" : [ { + "id" : "1221c19d-1636-4c1f-8fcd-52259a18677c", + "name" : "address", + "description" : "OpenID Connect built-in scope: address", + "protocol" : "openid-connect", + "attributes" : { + "include.in.token.scope" : "true", + "consent.screen.text" : "${addressScopeConsentText}", + "display.on.consent.screen" : "true" + }, + "protocolMappers" : [ { + "id" : "c01b6443-70e3-43e6-95c3-f67341edfde6", + "name" : "address", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-address-mapper", + "consentRequired" : false, + "config" : { + "user.attribute.formatted" : "formatted", + "user.attribute.country" : "country", + "introspection.token.claim" : "true", + "user.attribute.postal_code" : "postal_code", + "userinfo.token.claim" : "true", + "user.attribute.street" : "street", + "id.token.claim" : "true", + "user.attribute.region" : "region", + "access.token.claim" : "true", + "user.attribute.locality" : "locality" + } + } ] + }, { + "id" : "1aef006f-62fb-489b-98bf-7f4a5067585a", + "name" : "roles", + "description" : "OpenID Connect scope for add user roles to the access token", + "protocol" : "openid-connect", + "attributes" : { + "include.in.token.scope" : "false", + "consent.screen.text" : "${rolesScopeConsentText}", + "display.on.consent.screen" : "true" + }, + "protocolMappers" : [ { + "id" : "69f97c43-5270-43aa-8e5f-f35b448c0807", + "name" : "client roles", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usermodel-client-role-mapper", + "consentRequired" : false, + "config" : { + "user.attribute" : "foo", + "introspection.token.claim" : "true", + "access.token.claim" : "true", + "claim.name" : "resource_access.${client_id}.roles", + "jsonType.label" : "String", + "multivalued" : "true" + } + }, { + "id" : "9ac65150-845d-48c6-8802-686ec2ea39fc", + "name" : "audience resolve", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-audience-resolve-mapper", + "consentRequired" : false, + "config" : { + "introspection.token.claim" : "true", + "access.token.claim" : "true" + } + }, { + "id" : "6317df14-a875-47a4-b918-160a5745445e", + "name" : "realm roles", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usermodel-realm-role-mapper", + "consentRequired" : false, + "config" : { + "user.attribute" : "foo", + "introspection.token.claim" : "true", + "access.token.claim" : "true", + "claim.name" : "realm_access.roles", + "jsonType.label" : "String", + "multivalued" : "true" + } + } ] + }, { + "id" : "07e7b880-29b4-43ec-b29d-974802adaa60", + "name" : "microprofile-jwt", + "description" : "Microprofile - JWT built-in scope", + "protocol" : "openid-connect", + "attributes" : { + "include.in.token.scope" : "true", + "display.on.consent.screen" : "false" + }, + "protocolMappers" : [ { + "id" : "69aad238-17d9-4b02-93c5-7f7bb8f2db7b", + "name" : "groups", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usermodel-realm-role-mapper", + "consentRequired" : false, + "config" : { + "introspection.token.claim" : "true", + "multivalued" : "true", + "user.attribute" : "foo", + "id.token.claim" : "true", + "access.token.claim" : "true", + "claim.name" : "groups", + "jsonType.label" : "String" + } + }, { + "id" : "cf28c45c-099c-4e8d-a7d2-102f675838bf", + "name" : "upn", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usermodel-attribute-mapper", + "consentRequired" : false, + "config" : { + "introspection.token.claim" : "true", + "userinfo.token.claim" : "true", + "user.attribute" : "username", + "id.token.claim" : "true", + "access.token.claim" : "true", + "claim.name" : "upn", + "jsonType.label" : "String" + } + } ] + }, { + "id" : "92c42c03-28a3-4840-a82d-f1984410a9ae", + "name" : "acr", + "description" : "OpenID Connect scope for add acr (authentication context class reference) to the token", + "protocol" : "openid-connect", + "attributes" : { + "include.in.token.scope" : "false", + "display.on.consent.screen" : "false" + }, + "protocolMappers" : [ { + "id" : "559fcb0d-10e3-4dfc-a0f7-d8a94e7a1651", + "name" : "acr loa level", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-acr-mapper", + "consentRequired" : false, + "config" : { + "id.token.claim" : "true", + "introspection.token.claim" : "true", + "access.token.claim" : "true" + } + } ] + }, { + "id" : "14085adb-2b16-4e65-82b9-80629a0a5277", + "name" : "profile", + "description" : "OpenID Connect built-in scope: profile", + "protocol" : "openid-connect", + "attributes" : { + "include.in.token.scope" : "true", + "consent.screen.text" : "${profileScopeConsentText}", + "display.on.consent.screen" : "true" + }, + "protocolMappers" : [ { + "id" : "fc8413b3-61a4-413d-8a1b-303667a6959f", + "name" : "full name", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-full-name-mapper", + "consentRequired" : false, + "config" : { + "id.token.claim" : "true", + "introspection.token.claim" : "true", + "access.token.claim" : "true", + "userinfo.token.claim" : "true" + } + }, { + "id" : "48aff4e0-c99d-4aae-a3bc-91bab9d65596", + "name" : "picture", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usermodel-attribute-mapper", + "consentRequired" : false, + "config" : { + "introspection.token.claim" : "true", + "userinfo.token.claim" : "true", + "user.attribute" : "picture", + "id.token.claim" : "true", + "access.token.claim" : "true", + "claim.name" : "picture", + "jsonType.label" : "String" + } + }, { + "id" : "9820919e-a62f-424d-ad9d-589b6c8693e5", + "name" : "profile", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usermodel-attribute-mapper", + "consentRequired" : false, + "config" : { + "introspection.token.claim" : "true", + "userinfo.token.claim" : "true", + "user.attribute" : "profile", + "id.token.claim" : "true", + "access.token.claim" : "true", + "claim.name" : "profile", + "jsonType.label" : "String" + } + }, { + "id" : "82f7c323-6c57-464b-97e9-b5f0b0de3615", + "name" : "username", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usermodel-attribute-mapper", + "consentRequired" : false, + "config" : { + "introspection.token.claim" : "true", + "userinfo.token.claim" : "true", + "user.attribute" : "username", + "id.token.claim" : "true", + "access.token.claim" : "true", + "claim.name" : "preferred_username", + "jsonType.label" : "String" + } + }, { + "id" : "34eb1bf5-f3a5-4c2d-84c2-a63eb8357124", + "name" : "given name", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usermodel-attribute-mapper", + "consentRequired" : false, + "config" : { + "introspection.token.claim" : "true", + "userinfo.token.claim" : "true", + "user.attribute" : "firstName", + "id.token.claim" : "true", + "access.token.claim" : "true", + "claim.name" : "given_name", + "jsonType.label" : "String" + } + }, { + "id" : "a62b649f-b300-4036-bca2-c111b2380d00", + "name" : "website", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usermodel-attribute-mapper", + "consentRequired" : false, + "config" : { + "introspection.token.claim" : "true", + "userinfo.token.claim" : "true", + "user.attribute" : "website", + "id.token.claim" : "true", + "access.token.claim" : "true", + "claim.name" : "website", + "jsonType.label" : "String" + } + }, { + "id" : "fe2ea998-6e26-4b05-8d08-e774769272e7", + "name" : "zoneinfo", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usermodel-attribute-mapper", + "consentRequired" : false, + "config" : { + "introspection.token.claim" : "true", + "userinfo.token.claim" : "true", + "user.attribute" : "zoneinfo", + "id.token.claim" : "true", + "access.token.claim" : "true", + "claim.name" : "zoneinfo", + "jsonType.label" : "String" + } + }, { + "id" : "592ad19d-c53c-496e-be92-1a2b725f2beb", + "name" : "middle name", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usermodel-attribute-mapper", + "consentRequired" : false, + "config" : { + "introspection.token.claim" : "true", + "userinfo.token.claim" : "true", + "user.attribute" : "middleName", + "id.token.claim" : "true", + "access.token.claim" : "true", + "claim.name" : "middle_name", + "jsonType.label" : "String" + } + }, { + "id" : "9dd7f089-718a-4843-a6db-21b2f0f3bfed", + "name" : "nickname", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usermodel-attribute-mapper", + "consentRequired" : false, + "config" : { + "introspection.token.claim" : "true", + "userinfo.token.claim" : "true", + "user.attribute" : "nickname", + "id.token.claim" : "true", + "access.token.claim" : "true", + "claim.name" : "nickname", + "jsonType.label" : "String" + } + }, { + "id" : "8178127b-8094-4e0d-bd7c-3b2a7acdd330", + "name" : "locale", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usermodel-attribute-mapper", + "consentRequired" : false, + "config" : { + "introspection.token.claim" : "true", + "userinfo.token.claim" : "true", + "user.attribute" : "locale", + "id.token.claim" : "true", + "access.token.claim" : "true", + "claim.name" : "locale", + "jsonType.label" : "String" + } + }, { + "id" : "0ef67b63-dba3-417c-a14f-b215e90e7e64", + "name" : "family name", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usermodel-attribute-mapper", + "consentRequired" : false, + "config" : { + "introspection.token.claim" : "true", + "userinfo.token.claim" : "true", + "user.attribute" : "lastName", + "id.token.claim" : "true", + "access.token.claim" : "true", + "claim.name" : "family_name", + "jsonType.label" : "String" + } + }, { + "id" : "00621743-68d8-42ea-850e-0ecc7a930a52", + "name" : "gender", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usermodel-attribute-mapper", + "consentRequired" : false, + "config" : { + "introspection.token.claim" : "true", + "userinfo.token.claim" : "true", + "user.attribute" : "gender", + "id.token.claim" : "true", + "access.token.claim" : "true", + "claim.name" : "gender", + "jsonType.label" : "String" + } + }, { + "id" : "11e1a8a5-3022-4354-8aac-cfc031a37306", + "name" : "birthdate", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usermodel-attribute-mapper", + "consentRequired" : false, + "config" : { + "introspection.token.claim" : "true", + "userinfo.token.claim" : "true", + "user.attribute" : "birthdate", + "id.token.claim" : "true", + "access.token.claim" : "true", + "claim.name" : "birthdate", + "jsonType.label" : "String" + } + }, { + "id" : "01ad3992-3785-4288-978c-328969fa46af", + "name" : "updated at", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usermodel-attribute-mapper", + "consentRequired" : false, + "config" : { + "introspection.token.claim" : "true", + "userinfo.token.claim" : "true", + "user.attribute" : "updatedAt", + "id.token.claim" : "true", + "access.token.claim" : "true", + "claim.name" : "updated_at", + "jsonType.label" : "long" + } + } ] + }, { + "id" : "2e099e5c-44b7-4810-8612-b4e9003b2c12", + "name" : "email", + "description" : "OpenID Connect built-in scope: email", + "protocol" : "openid-connect", + "attributes" : { + "include.in.token.scope" : "true", + "consent.screen.text" : "${emailScopeConsentText}", + "display.on.consent.screen" : "true" + }, + "protocolMappers" : [ { + "id" : "302a39bf-f300-4ece-b54e-df6138fe9448", + "name" : "email", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usermodel-attribute-mapper", + "consentRequired" : false, + "config" : { + "introspection.token.claim" : "true", + "userinfo.token.claim" : "true", + "user.attribute" : "email", + "id.token.claim" : "true", + "access.token.claim" : "true", + "claim.name" : "email", + "jsonType.label" : "String" + } + }, { + "id" : "bafed855-153e-412a-9c2e-83cdaad5ee70", + "name" : "email verified", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usermodel-property-mapper", + "consentRequired" : false, + "config" : { + "introspection.token.claim" : "true", + "userinfo.token.claim" : "true", + "user.attribute" : "emailVerified", + "id.token.claim" : "true", + "access.token.claim" : "true", + "claim.name" : "email_verified", + "jsonType.label" : "boolean" + } + } ] + }, { + "id" : "e04245ff-2927-405a-bcaa-2f4f71a70bc6", + "name" : "web-origins", + "description" : "OpenID Connect scope for add allowed web origins to the access token", + "protocol" : "openid-connect", + "attributes" : { + "include.in.token.scope" : "false", + "consent.screen.text" : "", + "display.on.consent.screen" : "false" + }, + "protocolMappers" : [ { + "id" : "a2282242-f273-4ec4-93be-84489bd7d99f", + "name" : "allowed web origins", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-allowed-origins-mapper", + "consentRequired" : false, + "config" : { + "introspection.token.claim" : "true", + "access.token.claim" : "true" + } + } ] + }, { + "id" : "4f386944-8378-40a0-8743-de881ec19d9b", + "name" : "offline_access", + "description" : "OpenID Connect built-in scope: offline_access", + "protocol" : "openid-connect", + "attributes" : { + "consent.screen.text" : "${offlineAccessScopeConsentText}", + "display.on.consent.screen" : "true" + } + }, { + "id" : "e7b1d3d6-95ef-4192-a96a-b850416cf787", + "name" : "basic", + "description" : "OpenID Connect scope for add all basic claims to the token", + "protocol" : "openid-connect", + "attributes" : { + "include.in.token.scope" : "false", + "display.on.consent.screen" : "false" + }, + "protocolMappers" : [ { + "id" : "c42acb5e-4970-40e8-bfdc-eb8d53a4c168", + "name" : "sub", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-sub-mapper", + "consentRequired" : false, + "config" : { + "introspection.token.claim" : "true", + "access.token.claim" : "true" + } + }, { + "id" : "827625d4-4c5f-4a61-80db-4a14ca31a88c", + "name" : "auth_time", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usersessionmodel-note-mapper", + "consentRequired" : false, + "config" : { + "user.session.note" : "AUTH_TIME", + "id.token.claim" : "true", + "introspection.token.claim" : "true", + "access.token.claim" : "true", + "claim.name" : "auth_time", + "jsonType.label" : "long" + } + } ] + }, { + "id" : "6fb16c17-10e7-4236-aad4-7ef339fc23a5", + "name" : "role_list", + "description" : "SAML role list", + "protocol" : "saml", + "attributes" : { + "consent.screen.text" : "${samlRoleListScopeConsentText}", + "display.on.consent.screen" : "true" + }, + "protocolMappers" : [ { + "id" : "7f656caf-034a-45fa-b0dc-83e855116b37", + "name" : "role list", + "protocol" : "saml", + "protocolMapper" : "saml-role-list-mapper", + "consentRequired" : false, + "config" : { + "single" : "false", + "attribute.nameformat" : "Basic", + "attribute.name" : "Role" + } + } ] + }, { + "id" : "fb11ae77-ecab-4a24-8e99-4d010d63ac68", + "name" : "phone", + "description" : "OpenID Connect built-in scope: phone", + "protocol" : "openid-connect", + "attributes" : { + "include.in.token.scope" : "true", + "consent.screen.text" : "${phoneScopeConsentText}", + "display.on.consent.screen" : "true" + }, + "protocolMappers" : [ { + "id" : "73f7902e-95c9-4c0d-b316-d8797c6ee1fc", + "name" : "phone number", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usermodel-attribute-mapper", + "consentRequired" : false, + "config" : { + "introspection.token.claim" : "true", + "userinfo.token.claim" : "true", + "user.attribute" : "phoneNumber", + "id.token.claim" : "true", + "access.token.claim" : "true", + "claim.name" : "phone_number", + "jsonType.label" : "String" + } + }, { + "id" : "ae142c8a-9dc3-4860-8e00-6b55de0f4e6e", + "name" : "phone number verified", + "protocol" : "openid-connect", + "protocolMapper" : "oidc-usermodel-attribute-mapper", + "consentRequired" : false, + "config" : { + "introspection.token.claim" : "true", + "userinfo.token.claim" : "true", + "user.attribute" : "phoneNumberVerified", + "id.token.claim" : "true", + "access.token.claim" : "true", + "claim.name" : "phone_number_verified", + "jsonType.label" : "boolean" + } + } ] + } ], + "defaultDefaultClientScopes" : [ "role_list", "profile", "email", "roles", "web-origins", "acr", "basic" ], + "defaultOptionalClientScopes" : [ "offline_access", "address", "phone", "microprofile-jwt" ], + "browserSecurityHeaders" : { + "contentSecurityPolicyReportOnly" : "", + "xContentTypeOptions" : "nosniff", + "referrerPolicy" : "no-referrer", + "xRobotsTag" : "none", + "xFrameOptions" : "SAMEORIGIN", + "xXSSProtection" : "1; mode=block", + "contentSecurityPolicy" : "frame-src 'self'; frame-ancestors 'self'; object-src 'none';", + "strictTransportSecurity" : "max-age=31536000; includeSubDomains" + }, + "smtpServer" : { }, + "eventsEnabled" : false, + "eventsListeners" : [ "jboss-logging" ], + "enabledEventTypes" : [ ], + "adminEventsEnabled" : false, + "adminEventsDetailsEnabled" : false, + "identityProviders" : [ ], + "identityProviderMappers" : [ ], + "components" : { + "org.keycloak.services.clientregistration.policy.ClientRegistrationPolicy" : [ { + "id" : "009281c7-5818-43dd-9918-fc09fa64acee", + "name" : "Allowed Protocol Mapper Types", + "providerId" : "allowed-protocol-mappers", + "subType" : "authenticated", + "subComponents" : { }, + "config" : { + "allowed-protocol-mapper-types" : [ "saml-user-property-mapper", "oidc-usermodel-attribute-mapper", "oidc-sha256-pairwise-sub-mapper", "saml-role-list-mapper", "oidc-full-name-mapper", "oidc-address-mapper", "oidc-usermodel-property-mapper", "saml-user-attribute-mapper" ] + } + }, { + "id" : "07696e2b-e163-4d4e-9524-cb22f27b1b27", + "name" : "Max Clients Limit", + "providerId" : "max-clients", + "subType" : "anonymous", + "subComponents" : { }, + "config" : { + "max-clients" : [ "200" ] + } + }, { + "id" : "7e989075-ebb6-4bfb-84fa-e5333ebebad0", + "name" : "Allowed Client Scopes", + "providerId" : "allowed-client-templates", + "subType" : "anonymous", + "subComponents" : { }, + "config" : { + "allow-default-scopes" : [ "true" ] + } + }, { + "id" : "d5203c6d-ed3e-4634-b041-511a1891bff7", + "name" : "Allowed Protocol Mapper Types", + "providerId" : "allowed-protocol-mappers", + "subType" : "anonymous", + "subComponents" : { }, + "config" : { + "allowed-protocol-mapper-types" : [ "oidc-usermodel-property-mapper", "oidc-full-name-mapper", "oidc-address-mapper", "saml-user-attribute-mapper", "oidc-usermodel-attribute-mapper", "oidc-sha256-pairwise-sub-mapper", "saml-user-property-mapper", "saml-role-list-mapper" ] + } + }, { + "id" : "0e1d0a00-7daf-4f49-a717-44cb42ea4bac", + "name" : "Full Scope Disabled", + "providerId" : "scope", + "subType" : "anonymous", + "subComponents" : { }, + "config" : { } + }, { + "id" : "b30bab85-3c70-4709-8711-ff45a1aa1fbb", + "name" : "Allowed Client Scopes", + "providerId" : "allowed-client-templates", + "subType" : "authenticated", + "subComponents" : { }, + "config" : { + "allow-default-scopes" : [ "true" ] + } + }, { + "id" : "59fe5099-62a9-49c2-836d-a7af1da7ed4a", + "name" : "Trusted Hosts", + "providerId" : "trusted-hosts", + "subType" : "anonymous", + "subComponents" : { }, + "config" : { + "host-sending-registration-request-must-match" : [ "true" ], + "client-uris-must-match" : [ "true" ] + } + }, { + "id" : "345e252e-4519-48ce-9667-d73e0d4a825a", + "name" : "Consent Required", + "providerId" : "consent-required", + "subType" : "anonymous", + "subComponents" : { }, + "config" : { } + } ], + "org.keycloak.userprofile.UserProfileProvider" : [ { + "id" : "690e469b-9e09-45da-ae1b-a7fcf4ee4c36", + "providerId" : "declarative-user-profile", + "subComponents" : { }, + "config" : { + "kc.user.profile.config" : [ "{\"attributes\":[{\"name\":\"username\",\"displayName\":\"${username}\",\"validations\":{\"length\":{\"min\":3,\"max\":255},\"username-prohibited-characters\":{},\"up-username-not-idn-homograph\":{}},\"permissions\":{\"view\":[\"admin\",\"user\"],\"edit\":[\"admin\",\"user\"]},\"multivalued\":false},{\"name\":\"email\",\"displayName\":\"${email}\",\"validations\":{\"email\":{},\"length\":{\"max\":255}},\"permissions\":{\"view\":[\"admin\",\"user\"],\"edit\":[\"admin\",\"user\"]},\"multivalued\":false},{\"name\":\"firstName\",\"displayName\":\"${firstName}\",\"validations\":{\"length\":{\"max\":255},\"person-name-prohibited-characters\":{}},\"permissions\":{\"view\":[\"admin\",\"user\"],\"edit\":[\"admin\",\"user\"]},\"multivalued\":false},{\"name\":\"lastName\",\"displayName\":\"${lastName}\",\"validations\":{\"length\":{\"max\":255},\"person-name-prohibited-characters\":{}},\"permissions\":{\"view\":[\"admin\",\"user\"],\"edit\":[\"admin\",\"user\"]},\"multivalued\":false}],\"groups\":[{\"name\":\"user-metadata\",\"displayHeader\":\"User metadata\",\"displayDescription\":\"Attributes, which refer to user metadata\"}]}" ] + } + } ], + "org.keycloak.keys.KeyProvider" : [ { + "id" : "91dee151-ba0b-4a97-bce9-c170b077a012", + "name" : "rsa-generated", + "providerId" : "rsa-generated", + "subComponents" : { }, + "config" : { + "privateKey" : [ "MIIEogIBAAKCAQEAsf9ld3Rv2WqQgIIblzjfvM0eDx2QSrD6ecz6+pTm/VBzZfQPgDcAO+oTeR7mX+qsoRcyD2XL9qrEnpI4nZZmb08eSKiZMq67HUZbgdekfXNHh9ttlau9BMSN5KVumO+UnjESNwqWiGljTLhwzcJapbwg2EThzP1c2DGKa/pm8JPiF6lKXBisFdJ7MgS76Cs3x2GyI9duPPWCzgLWlf9ReHTHYaXMxJZXffEZDufWXjy+9W+uwo9BAjMDVec9meC6A/T62/qNrg/0RnUGuRikTxsHJoAdpjr/34H1SGDLJHuaI3DjPnc933oJ0pEXZSvgaCUQPqq/AvIfo0IVxsdJawIDAQABAoIBAAGNs4lMlfUCAlkM7HYt9I3hXvu2UPLGE+/i2zlRxCIq7VUWXqOrAMRD+si0lia6Wi9FyB/VNYTtYdZWCzJDA1qCoScmHvYABtjOC6br6ErJPBOvjZ4crxwl4pCpUTc5kLYV5pdZtKqeURCGv/Z8McJlO6hmKFBburAGJuCYMIwKdktcc3ERoBCr/6djGzYc2hQ4Jxp//dAD5hJxcyrWxkucJb5BQlWa/tGu/I1UbKnn2a9M6bsVlbd+q0FNn0028IGMD+LspuFRUiK2XqGqYiHbDQqkBk2EL4kS2BZthDBKGK1G2ZmrusVNMhk8LQBgrYzjlI2tbMGxGhsdFct8EP0CgYEA5OOTJ3fVTDWZPL3aW9UGJHecz9b6Khr+IQ5fRhSbN1o+2VfMyA/9l7azvXds5ak8Z3VfpAb3zxSw4qPzz1BW5zbTztHd57pgUOqIYtzgEh2Wq12yFcK2yZ5bTJtXC+0Zb57PC+sLC584+LcQGiWXppmqLyH8mUT7hPaEfZQIvRcCgYEAxxSuz5TwzXw3cwO8Okmd3rMJChWVZSOfbMLRJn44pfYX5S6Y/DslEbck1Ta5RI0efO77cbM5tiQYTjWdnw7P3amdxL3mVm73KJpgTUz2xa5xBaZCFnqYr6mepoFAn3e4WyCQLKOwwATmnkDKoe0fzmXQea5f3agGiDRtMU2N0s0CgYBg4eL7pcFnDJjcGRBAJp7++JIrdzdUczB2FXtGUpqQh2Zq4LsRQ5N3kCHsZIx3eXbT496jsz9ZK8zjYbplWgAuxgpJVJO07jKujVdFYXCGund3+aTTiSONm7XRaz8hES7fDD1fMhHuzWpz/CiYqKjUH97lLGGbDpPX5/CUpwA76QKBgEz/B9lLLpMa3stB/5O/kyplrjJTRLOYQnmrI38yxDAT7Qv7qLmtGlourjU0dBU6Szdgqqk/+ysh7LCa9fplnSmLB3TnSWXWhwsSIfrjsHGmVRotQlQ1WEZgYPbe+KJ0iD3ea0SIjrzFaHh+cKCN+IY4RJg7Q+KTsOsO88hkyVxlAoGAFfOVRPvoScmjVUNCTdhqRMvQtgf8FGmwD1Huq993ON74sO51uMO5cn2sehviLx9UrLDfPWuknBF7UfrWepAqeQ91hTE9JxkyC9Z4uzBW2WRGj/0FgnpwcpSccOmrfYxytGuivuVRUI/1Vn+OYj6vqAmLt/zfQ9LZ38O2v7yklv8=" ], + "keyUse" : [ "SIG" ], + "certificate" : [ "MIICmzCCAYMCBgGVPfDnMjANBgkqhkiG9w0BAQsFADARMQ8wDQYDVQQDDAZtYXN0ZXIwHhcNMjUwMjI1MTYyNzMyWhcNMzUwMjI1MTYyOTEyWjARMQ8wDQYDVQQDDAZtYXN0ZXIwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCx/2V3dG/ZapCAghuXON+8zR4PHZBKsPp5zPr6lOb9UHNl9A+ANwA76hN5HuZf6qyhFzIPZcv2qsSekjidlmZvTx5IqJkyrrsdRluB16R9c0eH222Vq70ExI3kpW6Y75SeMRI3CpaIaWNMuHDNwlqlvCDYROHM/VzYMYpr+mbwk+IXqUpcGKwV0nsyBLvoKzfHYbIj12489YLOAtaV/1F4dMdhpczElld98RkO59ZePL71b67Cj0ECMwNV5z2Z4LoD9Prb+o2uD/RGdQa5GKRPGwcmgB2mOv/fgfVIYMske5ojcOM+dz3fegnSkRdlK+BoJRA+qr8C8h+jQhXGx0lrAgMBAAEwDQYJKoZIhvcNAQELBQADggEBAHSbnsOYnxRDunL0WetZt+SrxiTxq/NrQmgGRF/c84+yEIWtZ2Mrx6sy8Coz9lXArI6t1ab94BY7yUNiSoaeXCNjtFS2sGppiOkCCUKJDfXWS5Yrurw2APWuM1D3ZbcEbWQlsozX0OADQFK421aacfCKlIZKRhvmUbAy+OLecFDcYxex6vAf2dleaxanb99K3SdtAOYpcHIyEv0xyw3nHPApn5VI+obuImgQuziylunHowOZxOfw6UJtzcbTgfbpCmtZ9E0f0Sfl1G9773B8K30ehRIVN1kGHNAMEXpsui7SE+q/1MLQ0UwYXySRG4pqYavoYfZfQKo09xFQbzJ/7aI=" ], + "priority" : [ "100" ] + } + }, { + "id" : "0e32268c-d387-4f97-bf4f-171fe8854e28", + "name" : "rsa-enc-generated", + "providerId" : "rsa-enc-generated", + "subComponents" : { }, + "config" : { + "privateKey" : [ "MIIEowIBAAKCAQEAsEOKzZDIqT9KOJZTh/FtKwkCo5SqZWn5mshs1F/2PwyXV4d+arqpA8umwXnEWTfnXj0osgTxkOGdyfFq1DyIf+PxrJCI122ap6Fw4jqH4adU89DhI3n8R0dBX46xpV3d/z8QQOQqFNV6dG8LoTnaqf1kdWCcvLxwGMZW5BtJiug+U0khDIYehldua095FM/i+psduXC/69fr1mrbjSY4fCDG8stdY2VxqzfORZ917dTRpMUnHY20isxNuSjVp8tvCcLchIyvEOmY0H2HWZMI80SVwdMlWo8LWg+gd/uo5IpXCiUhhVMvzR4kYC7O/ygtnf2vmHgmOG8Sw1PZ/6oeWwIDAQABAoIBAA+FGvMntOtmFbhu0BMPuG+KHw/dNrWwQ5iT3jVC79E0YB0GYqSuI9SgOWIewSGj8PzUwMEP+18o5nZSCfh/DfvIy85IeawSTARHjEN3KOC/FZVURoaTppl2FjT+QPkRD0t6t4knLedgtqQK5QjAqda+53oIVDSwGOStjhgOqQaQb5GiSbxC4RFqPeCKII3WCcfXuyBO8436zVf9ymU1mfGujB+aozpjfJz1ITXBn6ebBsVGvKpV5nqG6/7Osn0sVfvNzxGR0Ig1Fr4pj1KPk9Pd0Q5WDTij5+FNz47fNdnrOgiD9v6+jWG5X8YcqgtCOhrg7DugRpt5Agh6o+vfgfUCgYEA+Iv3FKh6EFS1rzx6zKx+cOetMzfbK/4l4MXOzkPMy+gur8nOBjh+Ix9fZ4ws1/7I/6Re7pNY5ZK/imWQF2RMB5qppznbn+3zxb2Xyaf6jQJTi2duWIPpsqCbZlB0PAYHKBCPJlYnDKzA2fHy2Vs5PUvJBAcZySpQqwQmkHZXqw0CgYEAtYytnuCMYni7nrfvqh/YncFScf2kwZqWMNPTVXz9GMM3JqiRUpjTsrMS+dbNNhc1mBf6a4KpXB1ojNwu/U9faTPLJ/dFaApJ6vmCDoTQp0PDD6Amk+OpNI8OX+ClcxMJEyugyIMEeHg8wAn/WhrniTzX7FYkkdCJefsVDAaV9QcCgYEA2XDh1YHbYCQr2sXnjN4vtCkLPq7UIPW0P6PuXEe2iIF1Lrc3dfvAZovQ1G8u4Dx3ricoIkJoWFf9GF/oIn7NgS2O1MzqVk0/ojRO8c3yhaCOZHw1blzhfDPEIEuslMKVSjjKc7iVayJLiaCBXCTRu81sbAimR3asoiD8eBAAfJUCgYAvlKcMNJ2WUT4a4uoVFDOZZMlOxsMfWnxyX5HLne5XRxJ1N0ie9R7GfthCUmGGA58wNViURLJOSgbUpABMszi2QbTEzLGMCuZEhw/m7jghHklJFxgFOm1mMPf7eMzj0+FU/OIuqufJCvog+n3KB7MW7LNKZWlALf9Z0Mw7Up6KkQKBgHtPGXTfm3KkC9OBbBJvUtdkjmoPxjcgIrKmgeDN9wwhYOuaPTohqMoPseqWZ0VUEA/rxOJC2v4/mEi0YqsHXZ7H34uxVDlbkoAKd1NIMBsf1y6Tb9ZwdcI2QEWXC0gzEtsunIPUn9ahx9JHq8JRcUPTGa1qsLT0DRIK7Wob+Cr9" ], + "keyUse" : [ "ENC" ], + "certificate" : [ "MIICmzCCAYMCBgGVPfDoEjANBgkqhkiG9w0BAQsFADARMQ8wDQYDVQQDDAZtYXN0ZXIwHhcNMjUwMjI1MTYyNzMzWhcNMzUwMjI1MTYyOTEzWjARMQ8wDQYDVQQDDAZtYXN0ZXIwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQCwQ4rNkMipP0o4llOH8W0rCQKjlKplafmayGzUX/Y/DJdXh35quqkDy6bBecRZN+dePSiyBPGQ4Z3J8WrUPIh/4/GskIjXbZqnoXDiOofhp1Tz0OEjefxHR0FfjrGlXd3/PxBA5CoU1Xp0bwuhOdqp/WR1YJy8vHAYxlbkG0mK6D5TSSEMhh6GV25rT3kUz+L6mx25cL/r1+vWatuNJjh8IMbyy11jZXGrN85Fn3Xt1NGkxScdjbSKzE25KNWny28JwtyEjK8Q6ZjQfYdZkwjzRJXB0yVajwtaD6B3+6jkilcKJSGFUy/NHiRgLs7/KC2d/a+YeCY4bxLDU9n/qh5bAgMBAAEwDQYJKoZIhvcNAQELBQADggEBAAcyviR6ki9ZfrRdXcVMrpaEPiYEXVvK3fW4HguCwReYG0R+/6One0xj1sPyR0RrJdxcMSkep1DtHdkAUozj+O9A3iwrAAlhMyibeTBvluHbJF22HUy2V6Zgy1kJjzn8TCeG6QuE5W0QuyhoYy6TdgZbspalr1sUPcu5mMAP2Njd2qJqJI6331ry5PM8dRtQ0w5QUUcUJOCICJmx3/LD56vznSQY9wx412FkHvAWVk0nFg3c38Wiswl6Oh5J6HEFc84K7tELii35u1k8COgEl/OJq6QS8x1Ac/FHabAAhGB/9wiM/r/tAVIHyugy5DRYOK0oPmU/nHqM8CC7fdujiYc=" ], + "priority" : [ "100" ], + "algorithm" : [ "RSA-OAEP" ] + } + }, { + "id" : "54c7c1ed-d15b-4e22-9c98-31f3871c2aa7", + "name" : "hmac-generated-hs512", + "providerId" : "hmac-generated", + "subComponents" : { }, + "config" : { + "kid" : [ "aa2e6f2b-b3ff-4587-8709-1b8709fb0b8f" ], + "secret" : [ "NhgikLmbb68BCYzYbgkz6DlbsnPG3HRUoWapQcP5mBdfte2SEtMJ3EObeElGkI9R9HM2FLuz7sTX5C6pxttSqSLK1URh5Eew34eZmunh1A-ETSltOYI0TUftK94wAiT6EsdnakYA9YY-2XcBttOB-kQ_oip6yT7X9VDzUVm_xYg" ], + "priority" : [ "100" ], + "algorithm" : [ "HS512" ] + } + }, { + "id" : "4ae2ae85-f3ad-4b74-9262-513f2301e150", + "name" : "aes-generated", + "providerId" : "aes-generated", + "subComponents" : { }, + "config" : { + "kid" : [ "043b5384-abfb-48b7-9ab9-26e4e9f2e969" ], + "secret" : [ "gyPLMZi5idD0491xayzurQ" ], + "priority" : [ "100" ] + } + } ] + }, + "internationalizationEnabled" : false, + "supportedLocales" : [ ], + "authenticationFlows" : [ { + "id" : "1ce4b89e-6944-44f6-8738-53543a6defd7", + "alias" : "Account verification options", + "description" : "Method with which to verity the existing account", + "providerId" : "basic-flow", + "topLevel" : false, + "builtIn" : true, + "authenticationExecutions" : [ { + "authenticator" : "idp-email-verification", + "authenticatorFlow" : false, + "requirement" : "ALTERNATIVE", + "priority" : 10, + "autheticatorFlow" : false, + "userSetupAllowed" : false + }, { + "authenticatorFlow" : true, + "requirement" : "ALTERNATIVE", + "priority" : 20, + "autheticatorFlow" : true, + "flowAlias" : "Verify Existing Account by Re-authentication", + "userSetupAllowed" : false + } ] + }, { + "id" : "f5c5536c-3769-4c34-ab89-c5355a4c30b5", + "alias" : "Browser - Conditional OTP", + "description" : "Flow to determine if the OTP is required for the authentication", + "providerId" : "basic-flow", + "topLevel" : false, + "builtIn" : true, + "authenticationExecutions" : [ { + "authenticator" : "conditional-user-configured", + "authenticatorFlow" : false, + "requirement" : "REQUIRED", + "priority" : 10, + "autheticatorFlow" : false, + "userSetupAllowed" : false + }, { + "authenticator" : "auth-otp-form", + "authenticatorFlow" : false, + "requirement" : "REQUIRED", + "priority" : 20, + "autheticatorFlow" : false, + "userSetupAllowed" : false + } ] + }, { + "id" : "847ec3b3-c355-4eba-b906-31ffc9d914b3", + "alias" : "Direct Grant - Conditional OTP", + "description" : "Flow to determine if the OTP is required for the authentication", + "providerId" : "basic-flow", + "topLevel" : false, + "builtIn" : true, + "authenticationExecutions" : [ { + "authenticator" : "conditional-user-configured", + "authenticatorFlow" : false, + "requirement" : "REQUIRED", + "priority" : 10, + "autheticatorFlow" : false, + "userSetupAllowed" : false + }, { + "authenticator" : "direct-grant-validate-otp", + "authenticatorFlow" : false, + "requirement" : "REQUIRED", + "priority" : 20, + "autheticatorFlow" : false, + "userSetupAllowed" : false + } ] + }, { + "id" : "11e9b13c-6f1b-4e02-85f6-28891e0a8f36", + "alias" : "First broker login - Conditional OTP", + "description" : "Flow to determine if the OTP is required for the authentication", + "providerId" : "basic-flow", + "topLevel" : false, + "builtIn" : true, + "authenticationExecutions" : [ { + "authenticator" : "conditional-user-configured", + "authenticatorFlow" : false, + "requirement" : "REQUIRED", + "priority" : 10, + "autheticatorFlow" : false, + "userSetupAllowed" : false + }, { + "authenticator" : "auth-otp-form", + "authenticatorFlow" : false, + "requirement" : "REQUIRED", + "priority" : 20, + "autheticatorFlow" : false, + "userSetupAllowed" : false + } ] + }, { + "id" : "eab77aaf-01ea-494e-8e50-85042eaf9e4e", + "alias" : "Handle Existing Account", + "description" : "Handle what to do if there is existing account with same email/username like authenticated identity provider", + "providerId" : "basic-flow", + "topLevel" : false, + "builtIn" : true, + "authenticationExecutions" : [ { + "authenticator" : "idp-confirm-link", + "authenticatorFlow" : false, + "requirement" : "REQUIRED", + "priority" : 10, + "autheticatorFlow" : false, + "userSetupAllowed" : false + }, { + "authenticatorFlow" : true, + "requirement" : "REQUIRED", + "priority" : 20, + "autheticatorFlow" : true, + "flowAlias" : "Account verification options", + "userSetupAllowed" : false + } ] + }, { + "id" : "88493237-6699-4080-b8c1-a156a5176d87", + "alias" : "Reset - Conditional OTP", + "description" : "Flow to determine if the OTP should be reset or not. Set to REQUIRED to force.", + "providerId" : "basic-flow", + "topLevel" : false, + "builtIn" : true, + "authenticationExecutions" : [ { + "authenticator" : "conditional-user-configured", + "authenticatorFlow" : false, + "requirement" : "REQUIRED", + "priority" : 10, + "autheticatorFlow" : false, + "userSetupAllowed" : false + }, { + "authenticator" : "reset-otp", + "authenticatorFlow" : false, + "requirement" : "REQUIRED", + "priority" : 20, + "autheticatorFlow" : false, + "userSetupAllowed" : false + } ] + }, { + "id" : "5b7bae19-2412-4414-aebd-dc1b14229d20", + "alias" : "User creation or linking", + "description" : "Flow for the existing/non-existing user alternatives", + "providerId" : "basic-flow", + "topLevel" : false, + "builtIn" : true, + "authenticationExecutions" : [ { + "authenticatorConfig" : "create unique user config", + "authenticator" : "idp-create-user-if-unique", + "authenticatorFlow" : false, + "requirement" : "ALTERNATIVE", + "priority" : 10, + "autheticatorFlow" : false, + "userSetupAllowed" : false + }, { + "authenticatorFlow" : true, + "requirement" : "ALTERNATIVE", + "priority" : 20, + "autheticatorFlow" : true, + "flowAlias" : "Handle Existing Account", + "userSetupAllowed" : false + } ] + }, { + "id" : "03c35464-e68a-461e-a3ac-641f5fb4d4f1", + "alias" : "Verify Existing Account by Re-authentication", + "description" : "Reauthentication of existing account", + "providerId" : "basic-flow", + "topLevel" : false, + "builtIn" : true, + "authenticationExecutions" : [ { + "authenticator" : "idp-username-password-form", + "authenticatorFlow" : false, + "requirement" : "REQUIRED", + "priority" : 10, + "autheticatorFlow" : false, + "userSetupAllowed" : false + }, { + "authenticatorFlow" : true, + "requirement" : "CONDITIONAL", + "priority" : 20, + "autheticatorFlow" : true, + "flowAlias" : "First broker login - Conditional OTP", + "userSetupAllowed" : false + } ] + }, { + "id" : "51d5c0f0-e8c6-4dc6-9e9a-680977629ccd", + "alias" : "browser", + "description" : "browser based authentication", + "providerId" : "basic-flow", + "topLevel" : true, + "builtIn" : true, + "authenticationExecutions" : [ { + "authenticator" : "auth-cookie", + "authenticatorFlow" : false, + "requirement" : "ALTERNATIVE", + "priority" : 10, + "autheticatorFlow" : false, + "userSetupAllowed" : false + }, { + "authenticator" : "auth-spnego", + "authenticatorFlow" : false, + "requirement" : "DISABLED", + "priority" : 20, + "autheticatorFlow" : false, + "userSetupAllowed" : false + }, { + "authenticator" : "identity-provider-redirector", + "authenticatorFlow" : false, + "requirement" : "ALTERNATIVE", + "priority" : 25, + "autheticatorFlow" : false, + "userSetupAllowed" : false + }, { + "authenticatorFlow" : true, + "requirement" : "ALTERNATIVE", + "priority" : 30, + "autheticatorFlow" : true, + "flowAlias" : "forms", + "userSetupAllowed" : false + } ] + }, { + "id" : "9b3b00af-2c90-4db2-b3a0-21ef7dc6f14d", + "alias" : "clients", + "description" : "Base authentication for clients", + "providerId" : "client-flow", + "topLevel" : true, + "builtIn" : true, + "authenticationExecutions" : [ { + "authenticator" : "client-secret", + "authenticatorFlow" : false, + "requirement" : "ALTERNATIVE", + "priority" : 10, + "autheticatorFlow" : false, + "userSetupAllowed" : false + }, { + "authenticator" : "client-jwt", + "authenticatorFlow" : false, + "requirement" : "ALTERNATIVE", + "priority" : 20, + "autheticatorFlow" : false, + "userSetupAllowed" : false + }, { + "authenticator" : "client-secret-jwt", + "authenticatorFlow" : false, + "requirement" : "ALTERNATIVE", + "priority" : 30, + "autheticatorFlow" : false, + "userSetupAllowed" : false + }, { + "authenticator" : "client-x509", + "authenticatorFlow" : false, + "requirement" : "ALTERNATIVE", + "priority" : 40, + "autheticatorFlow" : false, + "userSetupAllowed" : false + } ] + }, { + "id" : "64605779-3c20-48a7-b17a-9f6888c737c8", + "alias" : "direct grant", + "description" : "OpenID Connect Resource Owner Grant", + "providerId" : "basic-flow", + "topLevel" : true, + "builtIn" : true, + "authenticationExecutions" : [ { + "authenticator" : "direct-grant-validate-username", + "authenticatorFlow" : false, + "requirement" : "REQUIRED", + "priority" : 10, + "autheticatorFlow" : false, + "userSetupAllowed" : false + }, { + "authenticator" : "direct-grant-validate-password", + "authenticatorFlow" : false, + "requirement" : "REQUIRED", + "priority" : 20, + "autheticatorFlow" : false, + "userSetupAllowed" : false + }, { + "authenticatorFlow" : true, + "requirement" : "CONDITIONAL", + "priority" : 30, + "autheticatorFlow" : true, + "flowAlias" : "Direct Grant - Conditional OTP", + "userSetupAllowed" : false + } ] + }, { + "id" : "2797213e-19fc-4ad3-a751-bfc8fa3b080c", + "alias" : "docker auth", + "description" : "Used by Docker clients to authenticate against the IDP", + "providerId" : "basic-flow", + "topLevel" : true, + "builtIn" : true, + "authenticationExecutions" : [ { + "authenticator" : "docker-http-basic-authenticator", + "authenticatorFlow" : false, + "requirement" : "REQUIRED", + "priority" : 10, + "autheticatorFlow" : false, + "userSetupAllowed" : false + } ] + }, { + "id" : "5c245468-1b0d-4a96-b6c3-08cc1ef30f70", + "alias" : "first broker login", + "description" : "Actions taken after first broker login with identity provider account, which is not yet linked to any Keycloak account", + "providerId" : "basic-flow", + "topLevel" : true, + "builtIn" : true, + "authenticationExecutions" : [ { + "authenticatorConfig" : "review profile config", + "authenticator" : "idp-review-profile", + "authenticatorFlow" : false, + "requirement" : "REQUIRED", + "priority" : 10, + "autheticatorFlow" : false, + "userSetupAllowed" : false + }, { + "authenticatorFlow" : true, + "requirement" : "REQUIRED", + "priority" : 20, + "autheticatorFlow" : true, + "flowAlias" : "User creation or linking", + "userSetupAllowed" : false + } ] + }, { + "id" : "c31bb0d6-1e1b-4bf1-88dd-a3618f103c19", + "alias" : "forms", + "description" : "Username, password, otp and other auth forms.", + "providerId" : "basic-flow", + "topLevel" : false, + "builtIn" : true, + "authenticationExecutions" : [ { + "authenticator" : "auth-username-password-form", + "authenticatorFlow" : false, + "requirement" : "REQUIRED", + "priority" : 10, + "autheticatorFlow" : false, + "userSetupAllowed" : false + }, { + "authenticatorFlow" : true, + "requirement" : "CONDITIONAL", + "priority" : 20, + "autheticatorFlow" : true, + "flowAlias" : "Browser - Conditional OTP", + "userSetupAllowed" : false + } ] + }, { + "id" : "d701b2f7-6ebe-427c-af36-372c30ee4848", + "alias" : "registration", + "description" : "registration flow", + "providerId" : "basic-flow", + "topLevel" : true, + "builtIn" : true, + "authenticationExecutions" : [ { + "authenticator" : "registration-page-form", + "authenticatorFlow" : true, + "requirement" : "REQUIRED", + "priority" : 10, + "autheticatorFlow" : true, + "flowAlias" : "registration form", + "userSetupAllowed" : false + } ] + }, { + "id" : "5c2ac93a-9217-4a7b-a321-18d097af10bb", + "alias" : "registration form", + "description" : "registration form", + "providerId" : "form-flow", + "topLevel" : false, + "builtIn" : true, + "authenticationExecutions" : [ { + "authenticator" : "registration-user-creation", + "authenticatorFlow" : false, + "requirement" : "REQUIRED", + "priority" : 20, + "autheticatorFlow" : false, + "userSetupAllowed" : false + }, { + "authenticator" : "registration-password-action", + "authenticatorFlow" : false, + "requirement" : "REQUIRED", + "priority" : 50, + "autheticatorFlow" : false, + "userSetupAllowed" : false + }, { + "authenticator" : "registration-recaptcha-action", + "authenticatorFlow" : false, + "requirement" : "DISABLED", + "priority" : 60, + "autheticatorFlow" : false, + "userSetupAllowed" : false + }, { + "authenticator" : "registration-terms-and-conditions", + "authenticatorFlow" : false, + "requirement" : "DISABLED", + "priority" : 70, + "autheticatorFlow" : false, + "userSetupAllowed" : false + } ] + }, { + "id" : "723ecc33-6566-45ef-b65d-9358a1869e49", + "alias" : "reset credentials", + "description" : "Reset credentials for a user if they forgot their password or something", + "providerId" : "basic-flow", + "topLevel" : true, + "builtIn" : true, + "authenticationExecutions" : [ { + "authenticator" : "reset-credentials-choose-user", + "authenticatorFlow" : false, + "requirement" : "REQUIRED", + "priority" : 10, + "autheticatorFlow" : false, + "userSetupAllowed" : false + }, { + "authenticator" : "reset-credential-email", + "authenticatorFlow" : false, + "requirement" : "REQUIRED", + "priority" : 20, + "autheticatorFlow" : false, + "userSetupAllowed" : false + }, { + "authenticator" : "reset-password", + "authenticatorFlow" : false, + "requirement" : "REQUIRED", + "priority" : 30, + "autheticatorFlow" : false, + "userSetupAllowed" : false + }, { + "authenticatorFlow" : true, + "requirement" : "CONDITIONAL", + "priority" : 40, + "autheticatorFlow" : true, + "flowAlias" : "Reset - Conditional OTP", + "userSetupAllowed" : false + } ] + }, { + "id" : "ac87002d-2028-41e9-84b9-7702f767541d", + "alias" : "saml ecp", + "description" : "SAML ECP Profile Authentication Flow", + "providerId" : "basic-flow", + "topLevel" : true, + "builtIn" : true, + "authenticationExecutions" : [ { + "authenticator" : "http-basic-authenticator", + "authenticatorFlow" : false, + "requirement" : "REQUIRED", + "priority" : 10, + "autheticatorFlow" : false, + "userSetupAllowed" : false + } ] + } ], + "authenticatorConfig" : [ { + "id" : "4aa16020-beed-49d4-9ec9-53b03e70398e", + "alias" : "create unique user config", + "config" : { + "require.password.update.after.registration" : "false" + } + }, { + "id" : "020a675f-c2a2-4dcd-846d-c341f46f97d0", + "alias" : "review profile config", + "config" : { + "update.profile.on.first.login" : "missing" + } + } ], + "requiredActions" : [ { + "alias" : "CONFIGURE_TOTP", + "name" : "Configure OTP", + "providerId" : "CONFIGURE_TOTP", + "enabled" : true, + "defaultAction" : false, + "priority" : 10, + "config" : { } + }, { + "alias" : "TERMS_AND_CONDITIONS", + "name" : "Terms and Conditions", + "providerId" : "TERMS_AND_CONDITIONS", + "enabled" : false, + "defaultAction" : false, + "priority" : 20, + "config" : { } + }, { + "alias" : "UPDATE_PASSWORD", + "name" : "Update Password", + "providerId" : "UPDATE_PASSWORD", + "enabled" : true, + "defaultAction" : false, + "priority" : 30, + "config" : { } + }, { + "alias" : "UPDATE_PROFILE", + "name" : "Update Profile", + "providerId" : "UPDATE_PROFILE", + "enabled" : true, + "defaultAction" : false, + "priority" : 40, + "config" : { } + }, { + "alias" : "VERIFY_EMAIL", + "name" : "Verify Email", + "providerId" : "VERIFY_EMAIL", + "enabled" : true, + "defaultAction" : false, + "priority" : 50, + "config" : { } + }, { + "alias" : "delete_account", + "name" : "Delete Account", + "providerId" : "delete_account", + "enabled" : false, + "defaultAction" : false, + "priority" : 60, + "config" : { } + }, { + "alias" : "webauthn-register", + "name" : "Webauthn Register", + "providerId" : "webauthn-register", + "enabled" : true, + "defaultAction" : false, + "priority" : 70, + "config" : { } + }, { + "alias" : "webauthn-register-passwordless", + "name" : "Webauthn Register Passwordless", + "providerId" : "webauthn-register-passwordless", + "enabled" : true, + "defaultAction" : false, + "priority" : 80, + "config" : { } + }, { + "alias" : "VERIFY_PROFILE", + "name" : "Verify Profile", + "providerId" : "VERIFY_PROFILE", + "enabled" : true, + "defaultAction" : false, + "priority" : 90, + "config" : { } + }, { + "alias" : "delete_credential", + "name" : "Delete Credential", + "providerId" : "delete_credential", + "enabled" : true, + "defaultAction" : false, + "priority" : 100, + "config" : { } + }, { + "alias" : "update_user_locale", + "name" : "Update User Locale", + "providerId" : "update_user_locale", + "enabled" : true, + "defaultAction" : false, + "priority" : 1000, + "config" : { } + } ], + "browserFlow" : "browser", + "registrationFlow" : "registration", + "directGrantFlow" : "direct grant", + "resetCredentialsFlow" : "reset credentials", + "clientAuthenticationFlow" : "clients", + "dockerAuthenticationFlow" : "docker auth", + "firstBrokerLoginFlow" : "first broker login", + "attributes" : { + "cibaBackchannelTokenDeliveryMode" : "poll", + "cibaExpiresIn" : "120", + "cibaAuthRequestedUserHint" : "login_hint", + "parRequestUriLifespan" : "60", + "cibaInterval" : "5", + "realmReusableOtpCode" : "false" + }, + "keycloakVersion" : "25.0.6", + "userManagedAccessAllowed" : false, + "organizationsEnabled" : false, + "clientProfiles" : { + "profiles" : [ ] + }, + "clientPolicies" : { + "policies" : [ ] + } +} \ No newline at end of file diff --git a/misc/keycloak/keycloak-export/master-users-0.json b/misc/keycloak/keycloak-export/master-users-0.json new file mode 100644 index 00000000..d2c221a8 --- /dev/null +++ b/misc/keycloak/keycloak-export/master-users-0.json @@ -0,0 +1,26 @@ +{ + "realm" : "master", + "users" : [ { + "id" : "3ba20234-cfa8-4ff7-ae64-dac4870470e5", + "username" : "admin", + "emailVerified" : false, + "createdTimestamp" : 1740500953408, + "enabled" : true, + "totp" : false, + "credentials" : [ { + "id" : "98ceebfb-8dbe-4842-a335-e900d84eb2ef", + "type" : "password", + "createdDate" : 1740500953499, + "secretData" : "{\"value\":\"J5GuTpzJwSHGrfMzp7yPoBBmyQZ+Ijk5AozNbxkHcI0=\",\"salt\":\"uVtNH1+Non3hg/mKYy3XMQ==\",\"additionalParameters\":{}}", + "credentialData" : "{\"hashIterations\":5,\"algorithm\":\"argon2\",\"additionalParameters\":{\"hashLength\":[\"32\"],\"memory\":[\"7168\"],\"type\":[\"id\"],\"version\":[\"1.3\"],\"parallelism\":[\"1\"]}}" + } ], + "disableableCredentialTypes" : [ ], + "requiredActions" : [ ], + "realmRoles" : [ "default-roles-master", "admin" ], + "clientRoles" : { + "cdk-test-realm-realm" : [ "view-identity-providers", "manage-realm", "query-users", "manage-users", "create-client", "view-events", "manage-clients", "view-clients", "view-authorization", "manage-authorization", "view-realm", "manage-identity-providers", "query-realms", "manage-events", "view-users", "query-clients", "query-groups" ] + }, + "notBefore" : 0, + "groups" : [ ] + } ] +} \ No newline at end of file