feat: add api key generation endpint + role authorization
This commit is contained in:
Generated
+87
-20
@@ -168,6 +168,15 @@ dependencies = [
|
|||||||
"generic-array",
|
"generic-array",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "block-buffer"
|
||||||
|
version = "0.12.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cdd35008169921d80bc60d3d0ab416eecb028c4cd653352907921d95084790be"
|
||||||
|
dependencies = [
|
||||||
|
"hybrid-array",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "bumpalo"
|
name = "bumpalo"
|
||||||
version = "3.20.2"
|
version = "3.20.2"
|
||||||
@@ -221,14 +230,17 @@ name = "chat"
|
|||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"axum",
|
"axum",
|
||||||
|
"base64",
|
||||||
"chrono",
|
"chrono",
|
||||||
"dotenvy",
|
"dotenvy",
|
||||||
"futures",
|
"futures",
|
||||||
"jsonwebtoken",
|
"jsonwebtoken",
|
||||||
"once_cell",
|
"once_cell",
|
||||||
|
"rand 0.8.6",
|
||||||
"reqwest",
|
"reqwest",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"sha2 0.11.0",
|
||||||
"sqlx",
|
"sqlx",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"tokio",
|
"tokio",
|
||||||
@@ -289,6 +301,12 @@ version = "0.9.6"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
|
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "const-oid"
|
||||||
|
version = "0.10.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "core-foundation"
|
name = "core-foundation"
|
||||||
version = "0.9.4"
|
version = "0.9.4"
|
||||||
@@ -324,6 +342,15 @@ dependencies = [
|
|||||||
"libc",
|
"libc",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cpufeatures"
|
||||||
|
version = "0.3.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201"
|
||||||
|
dependencies = [
|
||||||
|
"libc",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crc"
|
name = "crc"
|
||||||
version = "3.4.0"
|
version = "3.4.0"
|
||||||
@@ -364,6 +391,15 @@ dependencies = [
|
|||||||
"typenum",
|
"typenum",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crypto-common"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "77727bb15fa921304124b128af125e7e3b968275d1b108b379190264f4423710"
|
||||||
|
dependencies = [
|
||||||
|
"hybrid-array",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "deadpool"
|
name = "deadpool"
|
||||||
version = "0.12.3"
|
version = "0.12.3"
|
||||||
@@ -388,7 +424,7 @@ version = "0.7.10"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
|
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"const-oid",
|
"const-oid 0.9.6",
|
||||||
"pem-rfc7468",
|
"pem-rfc7468",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
]
|
]
|
||||||
@@ -408,12 +444,23 @@ version = "0.10.7"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"block-buffer",
|
"block-buffer 0.10.4",
|
||||||
"const-oid",
|
"const-oid 0.9.6",
|
||||||
"crypto-common",
|
"crypto-common 0.1.7",
|
||||||
"subtle",
|
"subtle",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "digest"
|
||||||
|
version = "0.11.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
|
||||||
|
dependencies = [
|
||||||
|
"block-buffer 0.12.0",
|
||||||
|
"const-oid 0.10.2",
|
||||||
|
"crypto-common 0.2.1",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "displaydoc"
|
name = "displaydoc"
|
||||||
version = "0.2.5"
|
version = "0.2.5"
|
||||||
@@ -764,7 +811,7 @@ version = "0.12.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
|
checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"digest",
|
"digest 0.10.7",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -821,6 +868,15 @@ version = "1.0.3"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hybrid-array"
|
||||||
|
version = "0.4.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "08d46837a0ed51fe95bd3b05de33cd64a1ee88fc797477ca48446872504507c5"
|
||||||
|
dependencies = [
|
||||||
|
"typenum",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "hyper"
|
name = "hyper"
|
||||||
version = "1.9.0"
|
version = "1.9.0"
|
||||||
@@ -1222,7 +1278,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf"
|
checksum = "d89e7ee0cfbedfc4da3340218492196241d89eefb6dab27de5df917a6d2e78cf"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"digest",
|
"digest 0.10.7",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1723,8 +1779,8 @@ version = "0.9.10"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d"
|
checksum = "b8573f03f5883dcaebdfcf4725caa1ecb9c15b2ef50c43a07b816e06799bb12d"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"const-oid",
|
"const-oid 0.9.6",
|
||||||
"digest",
|
"digest 0.10.7",
|
||||||
"num-bigint-dig",
|
"num-bigint-dig",
|
||||||
"num-integer",
|
"num-integer",
|
||||||
"num-traits",
|
"num-traits",
|
||||||
@@ -1957,8 +2013,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
|
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"cpufeatures",
|
"cpufeatures 0.2.17",
|
||||||
"digest",
|
"digest 0.10.7",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -1968,8 +2024,19 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
|||||||
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cfg-if",
|
"cfg-if",
|
||||||
"cpufeatures",
|
"cpufeatures 0.2.17",
|
||||||
"digest",
|
"digest 0.10.7",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sha2"
|
||||||
|
version = "0.11.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"cpufeatures 0.3.0",
|
||||||
|
"digest 0.11.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
@@ -2003,7 +2070,7 @@ version = "2.2.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
|
checksum = "77549399552de45a898a580c1b41d445bf730df867cc44e6c0233bbc4b8329de"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"digest",
|
"digest 0.10.7",
|
||||||
"rand_core 0.6.4",
|
"rand_core 0.6.4",
|
||||||
]
|
]
|
||||||
|
|
||||||
@@ -2103,7 +2170,7 @@ dependencies = [
|
|||||||
"rustls",
|
"rustls",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2",
|
"sha2 0.10.9",
|
||||||
"smallvec",
|
"smallvec",
|
||||||
"thiserror 2.0.18",
|
"thiserror 2.0.18",
|
||||||
"tokio",
|
"tokio",
|
||||||
@@ -2142,7 +2209,7 @@ dependencies = [
|
|||||||
"quote",
|
"quote",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2",
|
"sha2 0.10.9",
|
||||||
"sqlx-core",
|
"sqlx-core",
|
||||||
"sqlx-mysql",
|
"sqlx-mysql",
|
||||||
"sqlx-postgres",
|
"sqlx-postgres",
|
||||||
@@ -2165,7 +2232,7 @@ dependencies = [
|
|||||||
"bytes",
|
"bytes",
|
||||||
"chrono",
|
"chrono",
|
||||||
"crc",
|
"crc",
|
||||||
"digest",
|
"digest 0.10.7",
|
||||||
"dotenvy",
|
"dotenvy",
|
||||||
"either",
|
"either",
|
||||||
"futures-channel",
|
"futures-channel",
|
||||||
@@ -2186,7 +2253,7 @@ dependencies = [
|
|||||||
"rsa",
|
"rsa",
|
||||||
"serde",
|
"serde",
|
||||||
"sha1",
|
"sha1",
|
||||||
"sha2",
|
"sha2 0.10.9",
|
||||||
"smallvec",
|
"smallvec",
|
||||||
"sqlx-core",
|
"sqlx-core",
|
||||||
"stringprep",
|
"stringprep",
|
||||||
@@ -2225,7 +2292,7 @@ dependencies = [
|
|||||||
"rand 0.8.6",
|
"rand 0.8.6",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"sha2",
|
"sha2 0.10.9",
|
||||||
"smallvec",
|
"smallvec",
|
||||||
"sqlx-core",
|
"sqlx-core",
|
||||||
"stringprep",
|
"stringprep",
|
||||||
@@ -2521,9 +2588,9 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "tower-http"
|
name = "tower-http"
|
||||||
version = "0.6.9"
|
version = "0.6.10"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "a28f0d049ccfaa566e14e9663d304d8577427b368cb4710a20528690287a738b"
|
checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"bitflags",
|
"bitflags",
|
||||||
"bytes",
|
"bytes",
|
||||||
|
|||||||
+4
-1
@@ -22,7 +22,10 @@ tokio-stream = "0.1"
|
|||||||
futures = "0.3"
|
futures = "0.3"
|
||||||
chrono = { version = "0.4.44", features = ["serde"] }
|
chrono = { version = "0.4.44", features = ["serde"] }
|
||||||
uuid = { version = "1.23.1", features = ["v4", "serde"] }
|
uuid = { version = "1.23.1", features = ["v4", "serde"] }
|
||||||
tower-http = { version = "0.6.9", features = ["cors"] }
|
tower-http = { version = "0.6.10", features = ["cors"] }
|
||||||
tracing = "0.1.44"
|
tracing = "0.1.44"
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter"]}
|
tracing-subscriber = { version = "0.3", features = ["env-filter"]}
|
||||||
sqlx = { version = "0.8.6", features = ["runtime-tokio-rustls", "postgres", "uuid", "chrono"] }
|
sqlx = { version = "0.8.6", features = ["runtime-tokio-rustls", "postgres", "uuid", "chrono"] }
|
||||||
|
rand = "0.8"
|
||||||
|
base64 = "0.22.1"
|
||||||
|
sha2 = "0.11.0"
|
||||||
@@ -263,3 +263,8 @@ This project turns Ollama into:
|
|||||||
👉 A local OpenAI-compatible API
|
👉 A local OpenAI-compatible API
|
||||||
👉 A controllable model runtime
|
👉 A controllable model runtime
|
||||||
👉 A foundation for a full LLM gateway
|
👉 A foundation for a full LLM gateway
|
||||||
|
|
||||||
|
# TODO
|
||||||
|
- open api doc for bearer token
|
||||||
|
- endpoint for creating token
|
||||||
|
- verify bearer token
|
||||||
|
|||||||
@@ -178,3 +178,14 @@ pub struct ChatDelta {
|
|||||||
pub role: Option<Role>,
|
pub role: Option<Role>,
|
||||||
pub content: Option<String>,
|
pub content: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Deserialize)]
|
||||||
|
pub struct CreateApiKeyRequest {
|
||||||
|
pub name: String,
|
||||||
|
pub scopes: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(serde::Serialize)]
|
||||||
|
pub struct CreateApiKeyResponse {
|
||||||
|
pub api_key: String, // ONLY returned once
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,58 +0,0 @@
|
|||||||
use jsonwebtoken::{Algorithm, DecodingKey, Validation, decode, decode_header};
|
|
||||||
use once_cell::sync::Lazy;
|
|
||||||
use serde::{Deserialize, Serialize};
|
|
||||||
use serde_json::Value;
|
|
||||||
use std::env;
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
|
||||||
pub struct Claims {
|
|
||||||
pub sub: String,
|
|
||||||
pub preferred_username: Option<String>,
|
|
||||||
pub exp: usize,
|
|
||||||
pub iss: String,
|
|
||||||
pub aud: Option<Vec<String>>,
|
|
||||||
pub realm_access: Option<RealmAccess>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Deserialize, Serialize, Clone)]
|
|
||||||
pub struct RealmAccess {
|
|
||||||
pub roles: Vec<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
static ISSUER: Lazy<String> = Lazy::new(|| env::var("ISSUER").expect("ISSUER not set"));
|
|
||||||
|
|
||||||
pub fn validate_token(token: &str, jwks: &Value) -> Result<Claims, String> {
|
|
||||||
// 1. Decode header
|
|
||||||
let header = decode_header(token).map_err(|_| "Invalid header")?;
|
|
||||||
|
|
||||||
let kid = header.kid.ok_or("Missing kid")?;
|
|
||||||
|
|
||||||
// 2. Find matching key
|
|
||||||
let keys = jwks["keys"].as_array().ok_or("Invalid JWKS")?;
|
|
||||||
|
|
||||||
let key = keys
|
|
||||||
.iter()
|
|
||||||
.find(|k| k["kid"] == kid)
|
|
||||||
.ok_or("Matching key not found")?;
|
|
||||||
|
|
||||||
// 3. Extract RSA components
|
|
||||||
let n = key["n"].as_str().ok_or("Missing n")?;
|
|
||||||
let e = key["e"].as_str().ok_or("Missing e")?;
|
|
||||||
|
|
||||||
let decoding_key =
|
|
||||||
DecodingKey::from_rsa_components(n, e).map_err(|_| "Invalid decoding key")?;
|
|
||||||
|
|
||||||
// 4. Setup validation rules
|
|
||||||
let mut validation = Validation::new(Algorithm::RS256);
|
|
||||||
|
|
||||||
validation.set_issuer(&[ISSUER.as_str()]);
|
|
||||||
|
|
||||||
validation.validate_exp = true;
|
|
||||||
validation.validate_aud = false;
|
|
||||||
|
|
||||||
// 5. Decode & verify
|
|
||||||
let token_data = decode::<Claims>(token, &decoding_key, &validation)
|
|
||||||
.map_err(|_| "Token validation failed")?;
|
|
||||||
|
|
||||||
Ok(token_data.claims)
|
|
||||||
}
|
|
||||||
@@ -1,10 +1,15 @@
|
|||||||
|
use jsonwebtoken::{Algorithm, DecodingKey, Validation, decode, decode_header};
|
||||||
use once_cell::sync::Lazy;
|
use once_cell::sync::Lazy;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
use serde_json::Value;
|
use serde_json::Value;
|
||||||
|
use std::collections::HashMap;
|
||||||
use std::env;
|
use std::env;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
use tokio::sync::RwLock;
|
use tokio::sync::RwLock;
|
||||||
|
|
||||||
|
// ------ JWKS ------
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Clone)]
|
||||||
struct JwksCache {
|
struct JwksCache {
|
||||||
jwks: Value,
|
jwks: Value,
|
||||||
@@ -55,3 +60,89 @@ pub async fn get_jwks() -> Result<Value, reqwest::Error> {
|
|||||||
// Expired or empty → refresh
|
// Expired or empty → refresh
|
||||||
refresh_jwks().await
|
refresh_jwks().await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ------ Claims ------
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||||
|
pub struct Claims {
|
||||||
|
pub sub: String,
|
||||||
|
pub preferred_username: Option<String>,
|
||||||
|
pub exp: usize,
|
||||||
|
pub iss: String,
|
||||||
|
pub aud: Option<Vec<String>>,
|
||||||
|
pub realm_access: Option<RealmAccess>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub resource_access: HashMap<String, ResourceAccess>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||||
|
pub struct RealmAccess {
|
||||||
|
pub roles: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize, Serialize, Clone)]
|
||||||
|
pub struct ResourceAccess {
|
||||||
|
pub roles: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Claims {
|
||||||
|
pub fn realm_roles(&self) -> &[String] {
|
||||||
|
self.realm_access
|
||||||
|
.as_ref()
|
||||||
|
.map_or(&[], |r| r.roles.as_slice())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn has_realm_role(&self, role: &str) -> bool {
|
||||||
|
self.realm_roles().iter().any(|r| r == role)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn client_roles(&self, client: &str) -> &[String] {
|
||||||
|
self.resource_access
|
||||||
|
.get(client)
|
||||||
|
.map_or(&[], |r| r.roles.as_slice())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn has_client_role(&self, client: &str, role: &str) -> bool {
|
||||||
|
self.client_roles(client).iter().any(|r| r == role)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ------ Claims ------
|
||||||
|
|
||||||
|
static ISSUER: Lazy<String> = Lazy::new(|| env::var("ISSUER").expect("ISSUER not set"));
|
||||||
|
|
||||||
|
pub fn validate_token(token: &str, jwks: &Value) -> Result<Claims, String> {
|
||||||
|
// 1. Decode header
|
||||||
|
let header = decode_header(token).map_err(|_| "Invalid header")?;
|
||||||
|
|
||||||
|
let kid = header.kid.ok_or("Missing kid")?;
|
||||||
|
|
||||||
|
// 2. Find matching key
|
||||||
|
let keys = jwks["keys"].as_array().ok_or("Invalid JWKS")?;
|
||||||
|
|
||||||
|
let key = keys
|
||||||
|
.iter()
|
||||||
|
.find(|k| k["kid"] == kid)
|
||||||
|
.ok_or("Matching key not found")?;
|
||||||
|
|
||||||
|
// 3. Extract RSA components
|
||||||
|
let n = key["n"].as_str().ok_or("Missing n")?;
|
||||||
|
let e = key["e"].as_str().ok_or("Missing e")?;
|
||||||
|
|
||||||
|
let decoding_key =
|
||||||
|
DecodingKey::from_rsa_components(n, e).map_err(|_| "Invalid decoding key")?;
|
||||||
|
|
||||||
|
// 4. Setup validation rules
|
||||||
|
let mut validation = Validation::new(Algorithm::RS256);
|
||||||
|
|
||||||
|
validation.set_issuer(&[ISSUER.as_str()]);
|
||||||
|
|
||||||
|
validation.validate_exp = true;
|
||||||
|
validation.validate_aud = false;
|
||||||
|
|
||||||
|
// 5. Decode & verify
|
||||||
|
let token_data = decode::<Claims>(token, &decoding_key, &validation)
|
||||||
|
.map_err(|_| "Token validation failed")?;
|
||||||
|
|
||||||
|
Ok(token_data.claims)
|
||||||
|
}
|
||||||
|
|||||||
@@ -6,17 +6,58 @@ use axum::{
|
|||||||
};
|
};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::middlewares::auth::{
|
use crate::middlewares::auth::keycloak::{Claims, get_jwks, refresh_jwks, validate_token};
|
||||||
jwt::validate_token,
|
|
||||||
keycloak::{get_jwks, refresh_jwks},
|
|
||||||
};
|
|
||||||
|
|
||||||
use crate::databases::postgres::user_repository::ensure_user_exists;
|
use crate::databases::postgres::user_repository::ensure_user_exists;
|
||||||
use crate::state::app_state::AppState;
|
use crate::state::app_state::AppState;
|
||||||
|
|
||||||
|
pub async fn require_roles(
|
||||||
|
request: Request<axum::body::Body>,
|
||||||
|
next: Next,
|
||||||
|
realm_role: Option<&'static str>,
|
||||||
|
client_role: Option<&'static str>,
|
||||||
|
) -> Result<Response, axum::http::StatusCode> {
|
||||||
|
let claims = request
|
||||||
|
.extensions()
|
||||||
|
.get::<Claims>()
|
||||||
|
.ok_or(axum::http::StatusCode::UNAUTHORIZED)?;
|
||||||
|
|
||||||
|
if realm_role.is_some_and(|role| !claims.has_realm_role(role)) {
|
||||||
|
return Err(axum::http::StatusCode::FORBIDDEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
if client_role.is_some_and(|role| !claims.has_client_role("chat-api", role)) {
|
||||||
|
return Err(axum::http::StatusCode::FORBIDDEN);
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(next.run(request).await)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_valid_claims(
|
||||||
|
state: &AppState,
|
||||||
|
mut request: Request,
|
||||||
|
next: Next,
|
||||||
|
claims: Claims,
|
||||||
|
) -> Result<Response, StatusCode> {
|
||||||
|
dbg!(&claims);
|
||||||
|
|
||||||
|
let user_id = claims
|
||||||
|
.sub
|
||||||
|
.parse::<Uuid>()
|
||||||
|
.map_err(|_| StatusCode::UNAUTHORIZED)?;
|
||||||
|
|
||||||
|
ensure_user_exists(&state.postgres, user_id)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
request.extensions_mut().insert(claims);
|
||||||
|
|
||||||
|
Ok(next.run(request).await)
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn auth_middleware(
|
pub async fn auth_middleware(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
mut request: Request,
|
request: Request,
|
||||||
next: Next,
|
next: Next,
|
||||||
) -> Result<Response, StatusCode> {
|
) -> Result<Response, StatusCode> {
|
||||||
tracing::debug!("Middleware hit");
|
tracing::debug!("Middleware hit");
|
||||||
@@ -40,38 +81,13 @@ pub async fn auth_middleware(
|
|||||||
match validate_token(token, &jwks) {
|
match validate_token(token, &jwks) {
|
||||||
Ok(claims) => {
|
Ok(claims) => {
|
||||||
tracing::debug!("Token valid");
|
tracing::debug!("Token valid");
|
||||||
|
handle_valid_claims(&state, request, next, claims).await
|
||||||
// Create user in db
|
|
||||||
let user_id = claims
|
|
||||||
.sub
|
|
||||||
.parse::<Uuid>()
|
|
||||||
.map_err(|_| StatusCode::UNAUTHORIZED)?;
|
|
||||||
|
|
||||||
ensure_user_exists(&state.postgres, user_id)
|
|
||||||
.await
|
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
|
||||||
|
|
||||||
request.extensions_mut().insert(claims);
|
|
||||||
|
|
||||||
Ok(next.run(request).await)
|
|
||||||
}
|
}
|
||||||
Err(_) => {
|
Err(_) => {
|
||||||
let jwks = refresh_jwks().await.map_err(|_| StatusCode::UNAUTHORIZED)?;
|
let jwks = refresh_jwks().await.map_err(|_| StatusCode::UNAUTHORIZED)?;
|
||||||
|
|
||||||
match validate_token(token, &jwks) {
|
match validate_token(token, &jwks) {
|
||||||
Ok(claims) => {
|
Ok(claims) => handle_valid_claims(&state, request, next, claims).await,
|
||||||
let user_id = claims
|
|
||||||
.sub
|
|
||||||
.parse::<Uuid>()
|
|
||||||
.map_err(|_| StatusCode::UNAUTHORIZED)?;
|
|
||||||
|
|
||||||
ensure_user_exists(&state.postgres, user_id)
|
|
||||||
.await
|
|
||||||
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
|
||||||
|
|
||||||
request.extensions_mut().insert(claims);
|
|
||||||
Ok(next.run(request).await)
|
|
||||||
}
|
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
tracing::error!("JWT validation failed: {:?}", e);
|
tracing::error!("JWT validation failed: {:?}", e);
|
||||||
Err(StatusCode::UNAUTHORIZED)
|
Err(StatusCode::UNAUTHORIZED)
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
pub mod jwt;
|
|
||||||
pub mod keycloak;
|
pub mod keycloak;
|
||||||
pub mod middleware;
|
pub mod middleware;
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
use axum::{
|
||||||
|
Json,
|
||||||
|
extract::{Extension, State},
|
||||||
|
http::StatusCode,
|
||||||
|
};
|
||||||
|
use base64::{Engine as _, engine::general_purpose};
|
||||||
|
use rand::RngCore;
|
||||||
|
use rand::rngs::OsRng;
|
||||||
|
use sha2::{Digest, Sha256};
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
use crate::dto::api::{CreateApiKeyRequest, CreateApiKeyResponse};
|
||||||
|
use crate::middlewares::auth::keycloak::Claims;
|
||||||
|
use crate::state::app_state::AppState;
|
||||||
|
|
||||||
|
fn generate_api_key() -> String {
|
||||||
|
let mut bytes = [0u8; 32];
|
||||||
|
OsRng.fill_bytes(&mut bytes);
|
||||||
|
general_purpose::URL_SAFE_NO_PAD.encode(bytes)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn hash_key(key: &str) -> String {
|
||||||
|
let mut hasher = Sha256::new();
|
||||||
|
hasher.update(key.as_bytes());
|
||||||
|
hasher
|
||||||
|
.finalize()
|
||||||
|
.iter()
|
||||||
|
.map(|b| format!("{:02x}", b))
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_api_key(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Extension(claims): Extension<Claims>,
|
||||||
|
Json(body): Json<CreateApiKeyRequest>,
|
||||||
|
) -> Result<Json<CreateApiKeyResponse>, StatusCode> {
|
||||||
|
let raw_key = generate_api_key();
|
||||||
|
let key_hash = hash_key(&raw_key);
|
||||||
|
|
||||||
|
dbg!(&claims);
|
||||||
|
|
||||||
|
let user_id = Uuid::parse_str(&claims.sub).map_err(|_| StatusCode::UNAUTHORIZED)?;
|
||||||
|
|
||||||
|
sqlx::query!(
|
||||||
|
r#"
|
||||||
|
INSERT INTO auth.api_key (key_hash, name, created_by, scopes)
|
||||||
|
VALUES ($1, $2, $3, $4)
|
||||||
|
"#,
|
||||||
|
key_hash,
|
||||||
|
body.name,
|
||||||
|
user_id,
|
||||||
|
&body.scopes
|
||||||
|
)
|
||||||
|
.execute(&state.postgres)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
|
||||||
|
|
||||||
|
Ok(Json(CreateApiKeyResponse { api_key: raw_key }))
|
||||||
|
}
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
|
pub mod apikey;
|
||||||
pub mod chat;
|
pub mod chat;
|
||||||
pub mod models;
|
pub mod models;
|
||||||
|
|
||||||
use crate::docs::ApiDoc;
|
use crate::docs::ApiDoc;
|
||||||
use crate::middlewares::auth::auth_middleware;
|
use crate::middlewares::auth::{auth_middleware, middleware::require_roles};
|
||||||
use crate::state::app_state::AppState;
|
use crate::state::app_state::AppState;
|
||||||
|
|
||||||
use axum::{Json, Router, middleware, routing::get, routing::post};
|
use axum::{Json, Router, middleware, routing::get, routing::post};
|
||||||
use utoipa::OpenApi;
|
use utoipa::OpenApi;
|
||||||
|
|
||||||
@@ -22,6 +24,12 @@ pub fn protected_router() -> Router<AppState> {
|
|||||||
.route("/chat/completions", post(chat::chat_completions))
|
.route("/chat/completions", post(chat::chat_completions))
|
||||||
.route("/models/{model}/load", post(models::load_model))
|
.route("/models/{model}/load", post(models::load_model))
|
||||||
.route("/models/{model}/unload", post(models::unload_model))
|
.route("/models/{model}/unload", post(models::unload_model))
|
||||||
|
.route(
|
||||||
|
"/keys/generate",
|
||||||
|
post(apikey::create_api_key).route_layer(middleware::from_fn(|req, next| {
|
||||||
|
require_roles(req, next, None, None)
|
||||||
|
})),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn router(state: AppState) -> Router<AppState> {
|
pub fn router(state: AppState) -> Router<AppState> {
|
||||||
|
|||||||
Reference in New Issue
Block a user