Эх сурвалжийг харах

Clear and Blind Auth (#510)

* feat: auth

* chore: corret error codes

* chore: corret error codes

* fix: feature auth in cdk-axum

* refactor: auth logging

* feat: include dleq in auth proof

* feat: mint max auth proofs

* chore: clippy
thesimplekid 1 сар өмнө
parent
commit
be93ff2384
91 өөрчлөгдсөн 11300 нэмэгдсэн , 503 устгасан
  1. 60 10
      .github/workflows/ci.yml
  2. 2 0
      .gitignore
  3. 4 1
      .typos.toml
  4. 4 0
      Cargo.toml
  5. 5 1
      crates/cashu/Cargo.toml
  6. 8 0
      crates/cashu/src/nuts/auth/mod.rs
  7. 409 0
      crates/cashu/src/nuts/auth/nut21.rs
  8. 369 0
      crates/cashu/src/nuts/auth/nut22.rs
  9. 8 0
      crates/cashu/src/nuts/mod.rs
  10. 5 0
      crates/cashu/src/nuts/nut00/mod.rs
  11. 20 2
      crates/cashu/src/nuts/nut02.rs
  12. 55 0
      crates/cashu/src/nuts/nut06.rs
  13. 4 1
      crates/cdk-axum/Cargo.toml
  14. 194 0
      crates/cdk-axum/src/auth.rs
  15. 13 1
      crates/cdk-axum/src/lib.rs
  16. 165 20
      crates/cdk-axum/src/router_handlers.rs
  17. 1 1
      crates/cdk-cli/Cargo.toml
  18. 47 10
      crates/cdk-cli/src/main.rs
  19. 196 0
      crates/cdk-cli/src/sub_commands/cat_device_login.rs
  20. 140 0
      crates/cdk-cli/src/sub_commands/cat_login.rs
  21. 2 0
      crates/cdk-cli/src/sub_commands/mint.rs
  22. 209 0
      crates/cdk-cli/src/sub_commands/mint_blind_auth.rs
  23. 2 2
      crates/cdk-cli/src/sub_commands/mint_info.rs
  24. 3 0
      crates/cdk-cli/src/sub_commands/mod.rs
  25. 62 0
      crates/cdk-cli/src/token_storage.rs
  26. 1 0
      crates/cdk-common/Cargo.toml
  27. 72 0
      crates/cdk-common/src/database/mint/auth/mod.rs
  28. 6 0
      crates/cdk-common/src/database/mint/mod.rs
  29. 6 0
      crates/cdk-common/src/database/mod.rs
  30. 81 1
      crates/cdk-common/src/error.rs
  31. 1 1
      crates/cdk-common/src/lib.rs
  32. 2 1
      crates/cdk-integration-tests/Cargo.toml
  33. 102 0
      crates/cdk-integration-tests/src/init_auth_mint.rs
  34. 26 6
      crates/cdk-integration-tests/src/init_pure_tests.rs
  35. 13 16
      crates/cdk-integration-tests/src/lib.rs
  36. 39 0
      crates/cdk-integration-tests/src/mock_oauth/mod.rs
  37. 866 0
      crates/cdk-integration-tests/tests/fake_auth.rs
  38. 21 22
      crates/cdk-integration-tests/tests/fake_wallet.rs
  39. 2 4
      crates/cdk-integration-tests/tests/regtest.rs
  40. 3 1
      crates/cdk-mintd/Cargo.toml
  41. 11 0
      crates/cdk-mintd/example.config.toml
  42. 25 0
      crates/cdk-mintd/src/config.rs
  43. 78 0
      crates/cdk-mintd/src/env_vars/auth.rs
  44. 23 0
      crates/cdk-mintd/src/env_vars/mod.rs
  45. 160 4
      crates/cdk-mintd/src/main.rs
  46. 2 1
      crates/cdk-redb/Cargo.toml
  47. 391 0
      crates/cdk-redb/src/mint/auth/mod.rs
  48. 10 5
      crates/cdk-redb/src/mint/mod.rs
  49. 2 1
      crates/cdk-sqlite/Cargo.toml
  50. 44 0
      crates/cdk-sqlite/src/mint/auth/migrations/20250109143347_init.sql
  51. 539 0
      crates/cdk-sqlite/src/mint/auth/mod.rs
  52. 12 7
      crates/cdk-sqlite/src/mint/mod.rs
  53. 13 5
      crates/cdk/Cargo.toml
  54. 1 1
      crates/cdk/README.md
  55. 150 0
      crates/cdk/examples/auth_wallet.rs
  56. 1 1
      crates/cdk/examples/p2pk.rs
  57. 1 1
      crates/cdk/examples/proof-selection.rs
  58. 1 1
      crates/cdk/examples/wallet.rs
  59. 9 1
      crates/cdk/src/lib.rs
  60. 413 0
      crates/cdk/src/mint/auth/mod.rs
  61. 87 2
      crates/cdk/src/mint/builder.rs
  62. 54 0
      crates/cdk/src/mint/issue/auth.rs
  63. 8 9
      crates/cdk/src/mint/issue/issue_nut04.rs
  64. 3 0
      crates/cdk/src/mint/issue/mod.rs
  65. 65 0
      crates/cdk/src/mint/keysets/auth.rs
  66. 8 1
      crates/cdk/src/mint/keysets/mod.rs
  67. 156 6
      crates/cdk/src/mint/mod.rs
  68. 3 0
      crates/cdk/src/mint/verification.rs
  69. 240 0
      crates/cdk/src/oidc_client.rs
  70. 30 0
      crates/cdk/src/wallet/auth/auth_connector.rs
  71. 427 0
      crates/cdk/src/wallet/auth/auth_wallet.rs
  72. 68 0
      crates/cdk/src/wallet/auth/mod.rs
  73. 161 0
      crates/cdk/src/wallet/builder.rs
  74. 0 308
      crates/cdk/src/wallet/client.rs
  75. 480 0
      crates/cdk/src/wallet/mint_connector/http_client.rs
  76. 83 0
      crates/cdk/src/wallet/mint_connector/mod.rs
  77. 97 40
      crates/cdk/src/wallet/mod.rs
  78. 1 0
      crates/cdk/src/wallet/proofs.rs
  79. 7 2
      crates/cdk/src/wallet/subscription/http.rs
  80. 24 4
      crates/cdk/src/wallet/subscription/mod.rs
  81. 7 1
      crates/cdk/src/wallet/subscription/ws.rs
  82. 3 0
      flake.nix
  83. 13 1
      justfile
  84. 109 0
      misc/fake_auth_itests.sh
  85. 7 0
      misc/keycloak/.env.example
  86. 45 0
      misc/keycloak/docker-compose-recover.yml
  87. 43 0
      misc/keycloak/docker-compose.yml
  88. 1854 0
      misc/keycloak/keycloak-export/cdk-test-realm-realm.json
  89. 27 0
      misc/keycloak/keycloak-export/cdk-test-realm-users-0.json
  90. 2050 0
      misc/keycloak/keycloak-export/master-realm.json
  91. 26 0
      misc/keycloak/keycloak-export/master-users-0.json

+ 60 - 10
.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
+

+ 2 - 0
.gitignore

@@ -10,3 +10,5 @@ config.toml
 result
 Cargo.lock
 .aider*
+**/postgres_data/
+**/.env

+ 4 - 1
.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"
 ]

+ 4 - 0
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"
 
 
 

+ 5 - 1
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"] }

+ 8 - 0
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,
+};

+ 409 - 0
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<ProtectedEndpoint>,
+}
+
+impl Settings {
+    /// Create new [`Settings`]
+    pub fn new(
+        openid_discovery: String,
+        client_id: String,
+        protected_endpoints: Vec<ProtectedEndpoint>,
+    ) -> 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<D>(deserializer: D) -> Result<Self, D::Error>
+    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<RawProtectedEndpoint>,
+        }
+
+        #[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<Vec<RoutePath>, 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, "<error>"),
+        };
+        // 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::<Vec<_>>();
+        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<ProtectedEndpoint> = 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::<Settings>(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()
+        );
+    }
+}

+ 369 - 0
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<ProtectedEndpoint>,
+}
+
+impl Settings {
+    /// Create new [`Settings`]
+    pub fn new(bat_max_mint: u64, protected_endpoints: Vec<ProtectedEndpoint>) -> 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<D>(deserializer: D) -> Result<Self, D::Error>
+    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<RawProtectedEndpoint>,
+        }
+
+        #[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<PublicKey, Error> {
+        Ok(hash_to_curve(self.secret.as_bytes())?)
+    }
+}
+
+impl From<AuthProof> 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<Proof> for AuthProof {
+    type Error = Error;
+    fn try_from(value: Proof) -> Result<Self, Self::Error> {
+        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<Self, Self::Err> {
+        // 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<BlindedMessage>,
+}
+
+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::<Vec<_>>();
+        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<ProtectedEndpoint> = 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::<Settings>(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()
+        );
+    }
+}

+ 8 - 0
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,

+ 5 - 0
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() {

+ 20 - 2
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<u64, D::Error>
+where
+    D: Deserializer<'de>,
+{
+    // This will either give us a u64 or null (which becomes None)
+    let opt = Option::<u64>::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]

+ 55 - 0
crates/cashu/src/nuts/nut06.rs

@@ -2,12 +2,17 @@
 //!
 //! <https://github.com/cashubtc/nuts/blob/main/06.md>
 
+#[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<ProtectedEndpoint, AuthRequired> {
+        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<String> {
+        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<String> {
+        self.nuts.nut21.as_ref().map(|s| s.client_id.clone())
+    }
+
+    /// Max bat mint
+    #[cfg(feature = "auth")]
+    pub fn bat_max_mint(&self) -> Option<u64> {
+        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<ClearAuthSettings>,
+    /// NUT22 Settings
+    #[serde(rename = "22")]
+    #[serde(skip_serializing_if = "Option::is_none")]
+    #[cfg(feature = "auth")]
+    pub nut22: Option<BlindAuthSettings>,
 }
 
 impl Nuts {

+ 4 - 1
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 }

+ 194 - 0
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<AuthHeader> for Option<AuthToken> {
+    fn from(value: AuthHeader) -> Option<AuthToken> {
+        match value {
+            AuthHeader::Clear(token) => Some(AuthToken::ClearAuth(token)),
+            AuthHeader::Blind(token) => Some(AuthToken::BlindAuth(token)),
+            AuthHeader::None => None,
+        }
+    }
+}
+
+impl<S> FromRequestParts<S> for AuthHeader
+where
+    S: Send + Sync,
+{
+    type Rejection = (StatusCode, String);
+
+    async fn from_request_parts(parts: &mut Parts, _state: &S) -> Result<Self, Self::Rejection> {
+        // 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<MintState>,
+) -> Result<Json<KeysetResponse>, 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<MintState>,
+) -> Result<Json<KeysResponse>, 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<MintState>,
+    Json(payload): Json<MintAuthRequest>,
+) -> Result<Json<MintBolt11Response>, 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<MintState> {
+    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)
+}

+ 13 - 1
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)
 }

+ 165 - 20
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 [<cache_ $handler>](
+                #[cfg(feature = "auth")] auth: AuthHeader,
                 state: State<MintState>,
                 payload: Json<$request_type>
             ) -> Result<Json<$response_type>, 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<MintState>) -> Result<Json<KeysResponse>, Response> {
+#[instrument(skip_all)]
+pub(crate) async fn get_keys(
+    State(state): State<MintState>,
+) -> Result<Json<KeysResponse>, 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<MintState>) -> Result<Json<KeysRespons
 /// Get the public keys of a specific keyset
 ///
 /// Get the public keys of the mint from a specific keyset ID.
-pub async fn get_keyset_pubkeys(
+#[instrument(skip_all, fields(keyset_id = ?keyset_id))]
+pub(crate) async fn get_keyset_pubkeys(
     State(state): State<MintState>,
     Path(keyset_id): Path<Id>,
 ) -> Result<Json<KeysResponse>, 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<MintState>) -> Result<Json<KeysetResponse>, Response> {
+#[instrument(skip_all)]
+pub(crate) async fn get_keysets(
+    State(state): State<MintState>,
+) -> Result<Json<KeysetResponse>, 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<MintState>) -> Result<Json<KeysetRe
 /// Request a quote for minting of new tokens
 ///
 /// Request minting of new tokens. The mint responds with a Lightning invoice. This endpoint can be used for a Lightning invoice UX flow.
-pub async fn post_mint_bolt11_quote(
+#[instrument(skip_all, fields(amount = ?payload.amount))]
+pub(crate) async fn post_mint_bolt11_quote(
+    #[cfg(feature = "auth")] auth: AuthHeader,
     State(state): State<MintState>,
     Json(payload): Json<MintQuoteBolt11Request>,
 ) -> Result<Json<MintQuoteBolt11Response<Uuid>>, 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<MintState>,
     Path(quote_id): Path<Uuid>,
 ) -> Result<Json<MintQuoteBolt11Response<Uuid>>, 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(&quote_id)
@@ -188,7 +229,11 @@ pub async fn get_check_mint_bolt11_quote(
     Ok(Json(quote))
 }
 
-pub async fn ws_handler(State(state): State<MintState>, ws: WebSocketUpgrade) -> impl IntoResponse {
+#[instrument(skip_all)]
+pub(crate) async fn ws_handler(
+    State(state): State<MintState>,
+    ws: WebSocketUpgrade,
+) -> impl IntoResponse {
     ws.on_upgrade(|ws| main_websocket(ws, state))
 }
 
@@ -207,10 +252,24 @@ pub async fn ws_handler(State(state): State<MintState>, 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<MintState>,
     Json(payload): Json<MintBolt11Request<Uuid>>,
 ) -> Result<Json<MintBolt11Response>, 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<MintState>,
     Json(payload): Json<MeltQuoteBolt11Request>,
 ) -> Result<Json<MeltQuoteBolt11Response<Uuid>>, 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<MintState>,
     Path(quote_id): Path<Uuid>,
 ) -> Result<Json<MeltQuoteBolt11Response<Uuid>>, 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(&quote_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<MintState>,
     Json(payload): Json<MeltBolt11Request<Uuid>>,
 ) -> Result<Json<MeltQuoteBolt11Response<Uuid>>, 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<MintState>,
     Json(payload): Json<CheckStateRequest>,
 ) -> Result<Json<CheckStateResponse>, 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<MintState>) -> Result<Json<MintInfo>, Response> {
+#[instrument(skip_all)]
+pub(crate) async fn get_mint_info(
+    State(state): State<MintState>,
+) -> Result<Json<MintInfo>, Response> {
     Ok(Json(
         state
             .mint
@@ -371,10 +486,24 @@ pub async fn get_mint_info(State(state): State<MintState>) -> Result<Json<MintIn
 /// Requests a set of Proofs to be swapped for another set of BlindSignatures.
 ///
 /// This endpoint can be used by Alice to swap a set of proofs before making a payment to Carol. It can then used by Carol to redeem the tokens for new proofs.
-pub async fn post_swap(
+#[instrument(skip_all, fields(inputs_count = ?payload.inputs.len()))]
+pub(crate) async fn post_swap(
+    #[cfg(feature = "auth")] auth: AuthHeader,
     State(state): State<MintState>,
     Json(payload): Json<SwapRequest>,
 ) -> Result<Json<SwapResponse>, 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<MintState>,
     Json(payload): Json<RestoreRequest>,
 ) -> Result<Json<RestoreResponse>, 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<T>(error: T) -> Response
+#[instrument(skip_all)]
+pub(crate) fn into_response<T>(error: T) -> Response
 where
     T: Into<ErrorResponse>,
 {

+ 1 - 1
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

+ 47 - 10
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
+        }
     }
 }

+ 196 - 0
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<dyn WalletDatabase<Err = Error> + 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);
+            }
+        }
+    }
+}

+ 140 - 0
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<dyn WalletDatabase<Err = Error> + 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(&params)
+        .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)
+}

+ 2 - 0
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

+ 209 - 0
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<u64>,
+    /// Cat (access token)
+    #[arg(long)]
+    cat: Option<String>,
+    /// 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<dyn WalletDatabase<Err = Error> + 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(&params).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))
+}

+ 2 - 2
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<Url>, 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?;

+ 3 - 0
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;

+ 62 - 0
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<Option<TokenData>> {
+    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)
+    }
+}

+ 1 - 0
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

+ 72 - 0
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<Error> + From<Error>;
+    /// 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<Option<Id>, 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<Option<MintKeySetInfo>, Self::Err>;
+    /// Get [`MintKeySetInfo`]s
+    async fn get_keyset_infos(&self) -> Result<Vec<MintKeySetInfo>, 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<Vec<Option<State>>, Self::Err>;
+    /// Get [`Proofs`] state
+    async fn update_proof_state(
+        &self,
+        y: &PublicKey,
+        proofs_state: State,
+    ) -> Result<Option<State>, 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<Vec<Option<BlindSignature>>, Self::Err>;
+
+    /// Add protected endpoints
+    async fn add_protected_endpoints(
+        &self,
+        protected_endpoints: HashMap<ProtectedEndpoint, AuthRequired>,
+    ) -> Result<(), Self::Err>;
+    /// Removed Protected endpoints
+    async fn remove_protected_endpoints(
+        &self,
+        protected_endpoints: Vec<ProtectedEndpoint>,
+    ) -> Result<(), Self::Err>;
+    /// Get auth for protected_endpoint
+    async fn get_auth_for_endpoint(
+        &self,
+        protected_endpoint: ProtectedEndpoint,
+    ) -> Result<Option<AuthRequired>, Self::Err>;
+    /// Get protected endpoints
+    async fn get_auth_for_endpoints(
+        &self,
+    ) -> Result<HashMap<ProtectedEndpoint, Option<AuthRequired>>, Self::Err>;
+}

+ 6 - 0
crates/cdk-common/src/database/mint.rs → 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 {

+ 6 - 0
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),

+ 81 - 1
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<Error> 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<ErrorResponse> 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,
         }
     }

+ 1 - 1
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;

+ 2 - 1
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

+ 102 - 0
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<D, A>(
+    _addr: &str,
+    _port: u16,
+    openid_discovery: String,
+    database: D,
+    auth_database: A,
+) -> Result<()>
+where
+    D: MintDatabase<Err = cdk_database::Error> + Send + Sync + 'static,
+    A: MintAuthDatabase<Err = cdk_database::Error> + 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<AuthWallet>, count: u64) {
+    let _proofs = auth_wallet
+        .mint_blind_auth(count.into())
+        .await
+        .expect("could not mint blind auth");
+}

+ 26 - 6
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<Mint>,
+    auth_wallet: Arc<RwLock<Option<AuthWallet>>>,
 }
 
 impl DirectMintConnection {
     pub fn new(mint: Arc<Mint>) -> 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<RestoreResponse, Error> {
         self.mint.restore(request).await
     }
+
+    /// Get the auth wallet for the client
+    async fn get_auth_wallet(&self) -> Option<AuthWallet> {
+        self.auth_wallet.read().await.clone()
+    }
+
+    /// Set auth wallet on client
+    async fn set_auth_wallet(&self, wallet: Option<AuthWallet>) {
+        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<Mint>) -> Result<Wallet> {
     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)
 }

+ 13 - 16
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<Wallet>,
-    amount: Amount,
-    split_target: SplitTarget,
-    description: Option<String>,
-) -> Result<()> {
-    let quote = wallet.mint_quote(amount, description).await?;
-
-    wait_for_mint_to_be_paid(&wallet, &quote.id, 60).await?;
-
-    let proofs = wallet.mint(&quote.id, split_target, None).await?;
+pub async fn fund_wallet(wallet: Arc<Wallet>, amount: Amount) {
+    let quote = wallet
+        .mint_quote(amount, None)
+        .await
+        .expect("Could not get mint quote");
 
-    let receive_amount = proofs.total_amount()?;
+    wait_for_mint_to_be_paid(&wallet, &quote.id, 60)
+        .await
+        .expect("Waiting for mint failed");
 
-    println!("Minted: {}", receive_amount);
-
-    Ok(())
+    let _proofs = wallet
+        .mint(&quote.id, SplitTarget::default(), None)
+        .await
+        .expect("Could not mint");
 }
 
 // Get all pending from wallet and attempt to swap

+ 39 - 0
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<Json<OidcConfig>> {
+    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<Json<JwkSet>> {
+    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<Json<Value>> {
+    Ok(Json(json!({"access_token": ""})))
+}

+ 866 - 0
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(&params)
+        .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(&params)
+        .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))
+}

+ 21 - 22
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 {

+ 2 - 4
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, &quote.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();
 

+ 3 - 1
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",

+ 11 - 0
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

+ 25 - 0
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<MintManagementRpc>,
+    pub auth: Option<Auth>,
 }
 
 #[derive(Debug, Clone, Serialize, Deserialize, Default)]

+ 78 - 0
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
+    }
+}

+ 23 - 0
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(

+ 160 - 4
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<dyn MintAuthDatabase<Err = cdk_database::Error> + 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.");

+ 2 - 1
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

+ 391 - 0
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<Database>,
+}
+
+const DATABASE_VERSION: u32 = 0;
+
+impl MintRedbAuthDatabase {
+    /// Create new [`MintRedbDatabase`]
+    pub fn new(path: &Path) -> Result<Self, Error> {
+        {
+            // 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<Option<Id>, 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<Option<MintKeySetInfo>, 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<Vec<MintKeySetInfo>, 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<Option<State>, 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<Vec<Option<State>>, 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<Vec<Option<BlindSignature>>, 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<ProtectedEndpoint, AuthRequired>,
+    ) -> 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<ProtectedEndpoint>,
+    ) -> 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<Option<AuthRequired>, 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<HashMap<ProtectedEndpoint, Option<AuthRequired>>, 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)
+    }
+}

+ 10 - 5
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,

+ 2 - 1
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]

+ 44 - 0
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
+);
+

+ 539 - 0
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<Self, Error> {
+        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<Option<Id>, 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<Option<MintKeySetInfo>, 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<Vec<MintKeySetInfo>, 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::<Result<_, _>>()?)
+            }
+            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<Vec<Option<State>>, 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::<Result<HashMap<_, _>, _>>()?;
+
+        Ok(ys.iter().map(|y| current_states.remove(y)).collect())
+    }
+
+    async fn update_proof_state(
+        &self,
+        y: &PublicKey,
+        proofs_state: State,
+    ) -> Result<Option<State>, 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<Vec<Option<BlindSignature>>, 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::<Result<HashMap<_, _>, _>>()?;
+
+        Ok(blinded_messages
+            .iter()
+            .map(|y| blinded_signatures.remove(y))
+            .collect())
+    }
+
+    async fn add_protected_endpoints(
+        &self,
+        protected_endpoints: HashMap<ProtectedEndpoint, AuthRequired>,
+    ) -> 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<ProtectedEndpoint>,
+    ) -> 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::<Vec<_>>()
+                .join(",")
+        );
+
+        let endpoints = protected_endpoints
+            .iter()
+            .map(serde_json::to_string)
+            .collect::<Result<Vec<_>, _>>()?;
+
+        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<Option<AuthRequired>, 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<HashMap<ProtectedEndpoint, Option<AuthRequired>>, 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())
+            }
+        }
+    }
+}

+ 12 - 7
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<MintKeySetInfo, Error> {
     let row_valid_to: Option<i64> = 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<i64> = row.try_get("input_fee_ppk").map_err(Error::from)?;
+    let row_keyset_ppk: Option<i64> = row.try_get("input_fee_ppk").ok();
     let row_derivation_path_index: Option<i64> =
         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,

+ 13 - 5
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"

+ 1 - 1
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();
 

+ 150 - 0
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(&quote.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(&params)
+        .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()
+}

+ 1 - 1
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?;

+ 1 - 1
crates/cdk/examples/proof-selection.rs

@@ -24,7 +24,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
     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] {

+ 1 - 1
crates/cdk/examples/wallet.rs

@@ -24,7 +24,7 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
     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?;

+ 9 - 1
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)]

+ 413 - 0
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<Option<AuthRequired>, 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<MintKeySet, Error> {
+        {
+            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<AuthToken>,
+        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<BlindSignature, Error> {
+        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)
+    }
+}

+ 87 - 2
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<Arc<dyn MintDatabase<Err = database::Error> + Send + Sync>>,
+    /// Mint Storage backend
+    #[cfg(feature = "auth")]
+    auth_localstore: Option<Arc<dyn MintAuthDatabase<Err = cdk_database::Error> + Send + Sync>>,
     /// Ln backends for mint
     ln: Option<
         HashMap<PaymentProcessorKey, Arc<dyn MintPayment<Err = cdk_payment::Error> + Send + Sync>>,
@@ -35,6 +43,8 @@ pub struct MintBuilder {
     seed: Option<Vec<u8>>,
     supported_units: HashMap<CurrencyUnit, (u64, u8)>,
     custom_paths: HashMap<CurrencyUnit, DerivationPath>,
+    // protected_endpoints: HashMap<ProtectedEndpoint, AuthRequired>,
+    openid_discovery: Option<String>,
 }
 
 impl MintBuilder {
@@ -66,6 +76,22 @@ impl MintBuilder {
         self
     }
 
+    /// Set auth localstore
+    #[cfg(feature = "auth")]
+    pub fn with_auth_localstore(
+        mut self,
+        localstore: Arc<dyn MintAuthDatabase<Err = cdk_database::Error> + 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<u8>) -> 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<Mint> {
         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(),
         )

+ 54 - 0
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<MintBolt11Response, Error> {
+        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,
+        })
+    }
+}

+ 8 - 9
crates/cdk/src/mint/mint_nut04.rs → 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<Uuid>,
-    ) -> Result<nut04::MintBolt11Response, Error> {
+        mint_request: MintBolt11Request<Uuid>,
+    ) -> Result<MintBolt11Response, Error> {
         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,
         })
     }

+ 3 - 0
crates/cdk/src/mint/issue/mod.rs

@@ -0,0 +1,3 @@
+#[cfg(feature = "auth")]
+mod auth;
+mod issue_nut04;

+ 65 - 0
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<KeysResponse, Error> {
+        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<KeysetResponse, Error> {
+        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 })
+    }
+}

+ 8 - 1
crates/cdk/src/mint/keysets.rs → 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<KeysResponse, Error> {
-        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,

+ 156 - 6
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<dyn MintDatabase<Err = database::Error> + Send + Sync>,
+    /// Auth Storage backend (only available with auth feature)
+    #[cfg(feature = "auth")]
+    pub auth_localstore: Option<Arc<dyn MintAuthDatabase<Err = database::Error> + Send + Sync>>,
     /// Ln backends for mint
     pub ln:
         HashMap<PaymentProcessorKey, Arc<dyn MintPayment<Err = cdk_payment::Error> + Send + Sync>>,
     /// Subscription manager
     pub pubsub_manager: Arc<PubSubManager>,
+    #[cfg(feature = "auth")]
+    oidc_client: Option<OidcClient>,
     secp_ctx: Secp256k1<secp256k1::All>,
     xpriv: Xpriv,
     keysets: Arc<RwLock<HashMap<Id, MintKeySet>>>,
@@ -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<dyn MintDatabase<Err = database::Error> + Send + Sync>,
@@ -63,10 +76,64 @@ impl Mint {
             PaymentProcessorKey,
             Arc<dyn MintPayment<Err = cdk_payment::Error> + Send + Sync>,
         >,
-        // Hashmap where the key is the unit and value is (input fee ppk, max_order)
         supported_units: HashMap<CurrencyUnit, (u64, u8)>,
         custom_paths: HashMap<CurrencyUnit, DerivationPath>,
     ) -> Result<Self, Error> {
+        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<dyn MintDatabase<Err = database::Error> + Send + Sync>,
+        auth_localstore: Arc<dyn MintAuthDatabase<Err = database::Error> + Send + Sync>,
+        ln: HashMap<
+            PaymentProcessorKey,
+            Arc<dyn MintPayment<Err = cdk_payment::Error> + Send + Sync>,
+        >,
+        supported_units: HashMap<CurrencyUnit, (u64, u8)>,
+        custom_paths: HashMap<CurrencyUnit, DerivationPath>,
+        open_id_discovery: String,
+    ) -> Result<Self, Error> {
+        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<dyn MintDatabase<Err = database::Error> + Send + Sync>,
+        #[cfg(feature = "auth")] auth_localstore: Option<
+            Arc<dyn database::MintAuthDatabase<Err = database::Error> + Send + Sync>,
+        >,
+        ln: HashMap<
+            PaymentProcessorKey,
+            Arc<dyn MintPayment<Err = cdk_payment::Error> + Send + Sync>,
+        >,
+        supported_units: HashMap<CurrencyUnit, (u64, u8)>,
+        custom_paths: HashMap<CurrencyUnit, DerivationPath>,
+        #[cfg(feature = "auth")] open_id_discovery: Option<String>,
+    ) -> Result<Self, Error> {
         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<MintInfo, Error> {
-        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<ProtectedEndpoint> = vec![];
+            let mut blind_auth_endpoints: Vec<ProtectedEndpoint> = 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

+ 3 - 0
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<CurrencyUnit>,
 }
 

+ 240 - 0
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<Error> 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<RwLock<Option<OidcConfig>>>,
+    jwks_set: Arc<RwLock<Option<JwkSet>>>,
+}
+
+#[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<String>,
+    pub expires_in: Option<i64>,
+    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<OidcConfig, Error> {
+        tracing::debug!("Getting oidc config");
+        let oidc_config = self
+            .client
+            .get(&self.openid_discovery)
+            .send()
+            .await?
+            .json::<OidcConfig>()
+            .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<JwkSet, Error> {
+        tracing::debug!("Getting jwks set");
+        let jwks_set = self
+            .client
+            .get(jwks_uri)
+            .send()
+            .await?
+            .json::<JwkSet>()
+            .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::<HashMap<String, serde_json::Value>>(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<TokenResponse, Error> {
+        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::<TokenResponse>()
+            .await?;
+
+        Ok(response)
+    }
+}

+ 30 - 0
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<AuthToken, Error>;
+
+    /// 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<MintInfo, Error>;
+    /// Get Blind Auth Keyset
+    async fn get_mint_blind_auth_keyset(&self, keyset_id: Id) -> Result<KeySet, Error>;
+    /// Get Blind Auth keysets
+    async fn get_mint_blind_auth_keysets(&self) -> Result<KeysetResponse, Error>;
+    /// Post mint blind auth
+    async fn post_mint_blind_auth(
+        &self,
+        request: MintAuthRequest,
+    ) -> Result<MintBolt11Response, Error>;
+}

+ 427 - 0
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<String>,
+    /// Expiration time (as UTC timestamp)
+    exp: Option<u64>,
+    /// Issued at (as UTC timestamp)
+    iat: Option<u64>,
+}
+/// 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<dyn WalletDatabase<Err = database::Error> + Send + Sync>,
+    /// Protected methods
+    pub protected_endpoints: Arc<RwLock<HashMap<ProtectedEndpoint, AuthRequired>>>,
+    /// Refresh token for auth
+    refresh_token: Arc<RwLock<Option<String>>>,
+    client: Arc<dyn AuthMintConnector + Send + Sync>,
+    /// OIDC client for authentication
+    oidc_client: Arc<RwLock<Option<OidcClient>>>,
+}
+
+impl AuthWallet {
+    /// Create a new [`AuthWallet`] instance
+    pub fn new(
+        mint_url: MintUrl,
+        cat: Option<AuthToken>,
+        localstore: Arc<dyn WalletDatabase<Err = database::Error> + Send + Sync>,
+        protected_endpoints: HashMap<ProtectedEndpoint, AuthRequired>,
+        oidc_client: Option<OidcClient>,
+    ) -> 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<AuthToken, Error> {
+        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<String> {
+        self.refresh_token.read().await.clone()
+    }
+
+    /// Set a new refresh token
+    #[instrument(skip(self))]
+    pub async fn set_refresh_token(&self, token: Option<String>) {
+        *self.refresh_token.write().await = token;
+    }
+
+    /// Get the OIDC client if one exists
+    #[instrument(skip(self))]
+    pub async fn get_oidc_client(&self) -> Option<OidcClient> {
+        self.oidc_client.read().await.clone()
+    }
+
+    /// Set a new OIDC client
+    #[instrument(skip(self))]
+    pub async fn set_oidc_client(&self, client: Option<OidcClient>) {
+        *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<Option<MintInfo>, 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<Keys, Error> {
+        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<Vec<KeySetInfo>, 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::<Vec<KeySetInfo>>();
+
+        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<KeySetInfo, Error> {
+        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<Vec<AuthProof>, 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::<Result<Vec<AuthProof>, _>>()?)
+    }
+
+    /// Check if and what kind of auth is required for a method
+    #[instrument(skip(self))]
+    pub async fn is_protected(&self, method: &ProtectedEndpoint) -> Option<AuthRequired> {
+        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<Option<AuthToken>, 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<Option<AuthToken>, 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<Proofs, Error> {
+        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::<Result<Vec<ProofInfo>, _>>()?;
+
+        // 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<Amount, Error> {
+        Ok(Amount::from(
+            self.get_unspent_auth_proofs().await?.len() as u64
+        ))
+    }
+}

+ 68 - 0
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<Proofs, Error> {
+        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<Vec<AuthProof>, 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(())
+    }
+}

+ 161 - 0
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<MintUrl>,
+    unit: Option<CurrencyUnit>,
+    localstore: Option<Arc<dyn WalletDatabase<Err = database::Error> + Send + Sync>>,
+    target_proof_count: Option<usize>,
+    #[cfg(feature = "auth")]
+    auth_wallet: Option<AuthWallet>,
+    seed: Option<Vec<u8>>,
+    client: Option<Arc<dyn MintConnector + Send + Sync>>,
+}
+
+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<dyn WalletDatabase<Err = database::Error> + 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<C: MintConnector + 'static + Send + Sync>(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<Wallet, Error> {
+        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<dyn MintConnector + Send + Sync>
+                }
+
+                #[cfg(not(feature = "auth"))]
+                {
+                    Arc::new(HttpClient::new(mint_url.clone()))
+                        as Arc<dyn MintConnector + Send + Sync>
+                }
+            }
+        };
+
+        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),
+        })
+    }
+}

+ 0 - 308
crates/cdk/src/wallet/client.rs

@@ -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<U: IntoUrl, R: DeserializeOwned>(&self, url: U) -> Result<R, Error> {
-        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::<R>(&response).map_err(|err| {
-            tracing::warn!("Http Response error: {}", err);
-            match ErrorResponse::from_json(&response) {
-                Ok(ok) => <ErrorResponse as Into<Error>>::into(ok),
-                Err(err) => err.into(),
-            }
-        })
-    }
-
-    #[inline]
-    async fn http_post<U: IntoUrl, P: Serialize + ?Sized, R: DeserializeOwned>(
-        &self,
-        url: U,
-        payload: &P,
-    ) -> Result<R, Error> {
-        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::<R>(&response).map_err(|err| {
-            tracing::warn!("Http Response error: {}", err);
-            match ErrorResponse::from_json(&response) {
-                Ok(ok) => <ErrorResponse as Into<Error>>::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<Self, Error> {
-        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<Vec<KeySet>, 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<KeySet, Error> {
-        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<KeysetResponse, Error> {
-        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<MintQuoteBolt11Response<String>, 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<MintQuoteBolt11Response<String>, 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<String>,
-    ) -> Result<MintBolt11Response, Error> {
-        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<MeltQuoteBolt11Response<String>, 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<MeltQuoteBolt11Response<String>, 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<String>,
-    ) -> Result<MeltQuoteBolt11Response<String>, 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<SwapResponse, Error> {
-        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<MintInfo, Error> {
-        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<CheckStateResponse, Error> {
-        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<RestoreResponse, Error> {
-        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<Vec<KeySet>, Error>;
-    /// Get Keyset Keys [NUT-01]
-    async fn get_mint_keyset(&self, keyset_id: Id) -> Result<KeySet, Error>;
-    /// Get Keysets [NUT-02]
-    async fn get_mint_keysets(&self) -> Result<KeysetResponse, Error>;
-    /// Mint Quote [NUT-04]
-    async fn post_mint_quote(
-        &self,
-        request: MintQuoteBolt11Request,
-    ) -> Result<MintQuoteBolt11Response<String>, Error>;
-    /// Mint Quote status
-    async fn get_mint_quote_status(
-        &self,
-        quote_id: &str,
-    ) -> Result<MintQuoteBolt11Response<String>, Error>;
-    /// Mint Tokens [NUT-04]
-    async fn post_mint(
-        &self,
-        request: MintBolt11Request<String>,
-    ) -> Result<MintBolt11Response, Error>;
-    /// Melt Quote [NUT-05]
-    async fn post_melt_quote(
-        &self,
-        request: MeltQuoteBolt11Request,
-    ) -> Result<MeltQuoteBolt11Response<String>, Error>;
-    /// Melt Quote Status
-    async fn get_melt_quote_status(
-        &self,
-        quote_id: &str,
-    ) -> Result<MeltQuoteBolt11Response<String>, Error>;
-    /// Melt [NUT-05]
-    /// [Nut-08] Lightning fee return if outputs defined
-    async fn post_melt(
-        &self,
-        request: MeltBolt11Request<String>,
-    ) -> Result<MeltQuoteBolt11Response<String>, Error>;
-    /// Split Token [NUT-06]
-    async fn post_swap(&self, request: SwapRequest) -> Result<SwapResponse, Error>;
-    /// Get Mint Info [NUT-06]
-    async fn get_mint_info(&self) -> Result<MintInfo, Error>;
-    /// Spendable check [NUT-07]
-    async fn post_check_state(
-        &self,
-        request: CheckStateRequest,
-    ) -> Result<CheckStateResponse, Error>;
-    /// Restore request [NUT-13]
-    async fn post_restore(&self, request: RestoreRequest) -> Result<RestoreResponse, Error>;
-}

+ 480 - 0
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<U: IntoUrl + Send, R: DeserializeOwned>(
+        &self,
+        url: U,
+        auth: Option<AuthToken>,
+    ) -> Result<R, Error> {
+        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::<R>(&response).map_err(|err| {
+            tracing::warn!("Http Response error: {}", err);
+            match ErrorResponse::from_json(&response) {
+                Ok(ok) => <ErrorResponse as Into<Error>>::into(ok),
+                Err(err) => err.into(),
+            }
+        })
+    }
+
+    async fn http_post<U: IntoUrl + Send, P: Serialize + ?Sized, R: DeserializeOwned>(
+        &self,
+        url: U,
+        auth_token: Option<AuthToken>,
+        payload: &P,
+    ) -> Result<R, Error> {
+        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::<R>(&response).map_err(|err| {
+            tracing::warn!("Http Response error: {}", err);
+            match ErrorResponse::from_json(&response) {
+                Ok(ok) => <ErrorResponse as Into<Error>>::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<RwLock<Option<AuthWallet>>>,
+}
+
+impl HttpClient {
+    /// Create new [`HttpClient`]
+    #[cfg(feature = "auth")]
+    pub fn new(mint_url: MintUrl, auth_wallet: Option<AuthWallet>) -> 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<Option<AuthToken>, 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<Self, Error> {
+        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<Vec<KeySet>, 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<KeySet, Error> {
+        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<KeysetResponse, Error> {
+        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<MintQuoteBolt11Response<String>, 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<MintQuoteBolt11Response<String>, 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<String>,
+    ) -> Result<MintBolt11Response, Error> {
+        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<MeltQuoteBolt11Response<String>, 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<MeltQuoteBolt11Response<String>, 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<String>,
+    ) -> Result<MeltQuoteBolt11Response<String>, 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<SwapResponse, Error> {
+        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<MintInfo, Error> {
+        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<AuthWallet> {
+        self.auth_wallet.read().await.clone()
+    }
+
+    #[cfg(feature = "auth")]
+    async fn set_auth_wallet(&self, wallet: Option<AuthWallet>) {
+        *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<CheckStateResponse, Error> {
+        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<RestoreResponse, Error> {
+        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<RwLock<AuthToken>>,
+}
+
+#[cfg(feature = "auth")]
+impl AuthHttpClient {
+    /// Create new [`AuthHttpClient`]
+    pub fn new(mint_url: MintUrl, cat: Option<AuthToken>) -> 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<AuthToken, Error> {
+        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<MintInfo, Error> {
+        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<KeySet, Error> {
+        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<KeysetResponse, Error> {
+        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<MintBolt11Response, Error> {
+        let url = self.mint_url.join_paths(&["v1", "auth", "blind", "mint"])?;
+        self.core
+            .http_post(url, Some(self.cat.read().await.clone()), &request)
+            .await
+    }
+}

+ 83 - 0
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<Vec<KeySet>, Error>;
+    /// Get Keyset Keys [NUT-01]
+    async fn get_mint_keyset(&self, keyset_id: Id) -> Result<KeySet, Error>;
+    /// Get Keysets [NUT-02]
+    async fn get_mint_keysets(&self) -> Result<KeysetResponse, Error>;
+    /// Mint Quote [NUT-04]
+    async fn post_mint_quote(
+        &self,
+        request: MintQuoteBolt11Request,
+    ) -> Result<MintQuoteBolt11Response<String>, Error>;
+    /// Mint Quote status
+    async fn get_mint_quote_status(
+        &self,
+        quote_id: &str,
+    ) -> Result<MintQuoteBolt11Response<String>, Error>;
+    /// Mint Tokens [NUT-04]
+    async fn post_mint(
+        &self,
+        request: MintBolt11Request<String>,
+    ) -> Result<MintBolt11Response, Error>;
+    /// Melt Quote [NUT-05]
+    async fn post_melt_quote(
+        &self,
+        request: MeltQuoteBolt11Request,
+    ) -> Result<MeltQuoteBolt11Response<String>, Error>;
+    /// Melt Quote Status
+    async fn get_melt_quote_status(
+        &self,
+        quote_id: &str,
+    ) -> Result<MeltQuoteBolt11Response<String>, Error>;
+    /// Melt [NUT-05]
+    /// [Nut-08] Lightning fee return if outputs defined
+    async fn post_melt(
+        &self,
+        request: MeltBolt11Request<String>,
+    ) -> Result<MeltQuoteBolt11Response<String>, Error>;
+    /// Split Token [NUT-06]
+    async fn post_swap(&self, request: SwapRequest) -> Result<SwapResponse, Error>;
+    /// Get Mint Info [NUT-06]
+    async fn get_mint_info(&self) -> Result<MintInfo, Error>;
+    /// Spendable check [NUT-07]
+    async fn post_check_state(
+        &self,
+        request: CheckStateRequest,
+    ) -> Result<CheckStateResponse, Error>;
+    /// Restore request [NUT-13]
+    async fn post_restore(&self, request: RestoreRequest) -> Result<RestoreResponse, Error>;
+
+    /// Get the auth wallet for the client
+    #[cfg(feature = "auth")]
+    async fn get_auth_wallet(&self) -> Option<AuthWallet>;
+
+    /// Set auth wallet on client
+    #[cfg(feature = "auth")]
+    async fn set_auth_wallet(&self, wallet: Option<AuthWallet>);
+}

+ 97 - 40
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<dyn WalletDatabase<Err = database::Error> + Send + Sync>,
     /// The targeted amount of proofs to have at each size
     pub target_proof_count: usize,
+    #[cfg(feature = "auth")]
+    auth_wallet: Arc<RwLock<Option<AuthWallet>>>,
     xpriv: Xpriv,
     client: Arc<dyn MintConnector + Send + Sync>,
     subscription: SubscriptionManager,
@@ -115,14 +129,16 @@ impl From<WalletSubscription> 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<usize>,
     ) -> Result<Self, Error> {
-        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<C: MintConnector + 'static + Send + Sync>(&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<T: Into<Params>>(&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<Option<MintInfo>, Error> {
-        let mint_info = match self.client.get_mint_info().await {
-            Ok(mint_info) => Some(mint_info),
-            Err(err) => {
-                tracing::warn!("Could not get mint info {}", err);
-                None
-            }
-        };
+        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);
+                    }
+                }
 
-        self.localstore
-            .add_mint(self.mint_url.clone(), mint_info.clone())
-            .await?;
+                // 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);
+                tracing::trace!("Mint info updated for {}", self.mint_url);
 
-        Ok(mint_info)
+                Ok(Some(mint_info))
+            }
+            Err(err) => {
+                tracing::warn!("Could not get mint info {}", err);
+                Ok(None)
+            }
+        }
     }
 
     /// Get amounts needed to refill proof state

+ 1 - 0
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()

+ 7 - 2
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<S: IntoIterator<Item = SubId>>(
     subscriptions: Arc<RwLock<HashMap<SubId, WsSubscriptionBody>>>,
     mut new_subscription_recv: mpsc::Receiver<SubId>,
     mut on_drop: mpsc::Receiver<SubId>,
+    _wallet: Arc<Wallet>,
 ) {
     let mut interval = time::interval(Duration::from_secs(2));
     let mut subscribed_to = HashMap::<UrlType, (mpsc::Sender<_>, _, AnyState)>::new();
@@ -92,6 +94,7 @@ pub async fn http_main<S: IntoIterator<Item = SubId>>(
                     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<S: IntoIterator<Item = SubId>>(
                             }
                         }
                         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<S: IntoIterator<Item = SubId>>(
                         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

+ 24 - 4
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<Wallet>,
+    ) -> 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<dyn MintConnector + Send + Sync>,
         prefer_ws_method: bool,
+        wallet: Arc<Wallet>,
     ) -> 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<RwLock<HashMap<SubId, WsSubscriptionBody>>>,
         new_subscription_recv: mpsc::Receiver<SubId>,
         on_drop_recv: mpsc::Receiver<SubId>,
+        wallet: Arc<Wallet>,
     ) -> 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<RwLock<HashMap<SubId, WsSubscriptionBody>>>,
         new_subscription_recv: mpsc::Receiver<SubId>,
         on_drop: mpsc::Receiver<SubId>,
+        wallet: Arc<Wallet>,
     ) -> 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<RwLock<HashMap<SubId, WsSubscriptionBody>>>,
         new_subscription_recv: mpsc::Receiver<SubId>,
         on_drop: mpsc::Receiver<SubId>,
+        wallet: Arc<Wallet>,
     ) -> JoinHandle<()> {
         tokio::spawn(ws::ws_main(
             http_client,
@@ -308,6 +327,7 @@ impl SubscriptionClient {
             subscriptions,
             new_subscription_recv,
             on_drop,
+            wallet,
         ))
     }
 }

+ 7 - 1
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<S: IntoIterator<Item = SubId>>(
     subscriptions: Arc<RwLock<HashMap<SubId, WsSubscriptionBody>>>,
     new_subscription_recv: mpsc::Receiver<SubId>,
     on_drop: mpsc::Receiver<SubId>,
+    wallet: Arc<Wallet>,
 ) {
     http_main(
         initial_state,
@@ -30,6 +32,7 @@ async fn fallback_to_http<S: IntoIterator<Item = SubId>>(
         subscriptions,
         new_subscription_recv,
         on_drop,
+        wallet,
     )
     .await
 }
@@ -41,6 +44,7 @@ pub async fn ws_main(
     subscriptions: Arc<RwLock<HashMap<SubId, WsSubscriptionBody>>>,
     mut new_subscription_recv: mpsc::Receiver<SubId>,
     mut on_drop: mpsc::Receiver<SubId>,
+    wallet: Arc<Wallet>,
 ) {
     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;
                             }
                         }

+ 3 - 0
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

+ 13 - 1
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

+ 109 - 0
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

+ 7 - 0
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

+ 45 - 0
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

+ 43 - 0
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

+ 1854 - 0
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" : [ ]
+  }
+}

+ 27 - 0
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" : [ ]
+  } ]
+}

+ 2050 - 0
misc/keycloak/keycloak-export/master-realm.json

@@ -0,0 +1,2050 @@
+{
+  "id" : "1d24ffca-4ac4-4768-b0ea-0e903339234f",
+  "realm" : "master",
+  "displayName" : "Keycloak",
+  "displayNameHtml" : "<div class=\"kc-logo-text\"><span>Keycloak</span></div>",
+  "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" : [ ]
+  }
+}

+ 26 - 0
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" : [ ]
+  } ]
+}