feat: oidc login (#5)

Co-authored-by: Nico Fricke <nfricke@eos-uptrade.de>
Reviewed-on: #5
Co-authored-by: Nico Fricke <nico@nifni.net>
Co-committed-by: Nico Fricke <nico@nifni.net>
This commit is contained in:
Nico Fricke 2025-01-22 19:40:01 +01:00 committed by nif
parent edf6e84a9a
commit 6bbe4ea6ae
10 changed files with 1244 additions and 44 deletions

1130
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@ -7,6 +7,8 @@ repository = "https://git.nifni.eu/nif/casket"
[dependencies] [dependencies]
axum = { version = "0.8.1", features = ["multipart", "macros"] } axum = { version = "0.8.1", features = ["multipart", "macros"] }
axum-extra = { version = "0.10.0", features = ["typed-header"] }
axum-jwks = "0.11.0"
tokio = { version = "1.42.0", features = ["full"] } tokio = { version = "1.42.0", features = ["full"] }
tokio-util = { version = "0.7.13", features = ["io"] } tokio-util = { version = "0.7.13", features = ["io"] }
futures = "0.3" futures = "0.3"

View File

@ -0,0 +1,57 @@
use crate::AppState;
use axum::extract::{FromRef, Path, Request, State};
use axum::http::StatusCode;
use axum::middleware::Next;
use axum::response::{IntoResponse, Response};
use axum_extra::headers::authorization::Bearer;
use axum_extra::headers::Authorization;
use axum_extra::TypedHeader;
use axum_jwks::Jwks;
use serde::{Deserialize, Serialize};
use tracing::debug;
#[derive(Deserialize, Serialize)]
struct Claims {
sub: String,
}
impl FromRef<AppState> for Jwks {
fn from_ref(state: &AppState) -> Self {
state.jwks.clone()
}
}
pub async fn validate_token(
State(state): State<AppState>,
user: Option<Path<String>>,
auth_header: Option<TypedHeader<Authorization<Bearer>>>,
request: Request,
next: Next,
) -> Response {
let jwks = Jwks::from_ref(&state);
if request.uri().path().starts_with("/api/v1/user/") {
if auth_header.is_none() {
debug!("No auth header passed.");
return StatusCode::UNAUTHORIZED.into_response();
}
match jwks.validate_claims::<Claims>(auth_header.unwrap().0 .0.token()) {
Err(err) => {
debug!("JWT validation failed: {:?}", err);
return StatusCode::UNAUTHORIZED.into_response();
}
Ok(jwk) => match user {
Some(user) => {
if !user.0.eq(&jwk.claims.sub) {
debug!("JWT sub {:?} does not match user {:?}", jwk.claims.sub, user.0);
return StatusCode::FORBIDDEN.into_response();
}
}
None => {
panic!("User was none")
}
},
}
}
next.run(request).await
}

View File

@ -9,6 +9,7 @@ pub const CONFIG_LOCATIONS: [&str; 2] = ["casket-backend/casket.toml", "/config/
pub struct Config { pub struct Config {
pub server: Server, pub server: Server,
pub files: Files, pub files: Files,
pub oidc: Oidc
} }
#[derive(Deserialize, Clone, Debug)] #[derive(Deserialize, Clone, Debug)]
@ -21,6 +22,11 @@ pub struct Files {
pub directory: PathBuf, pub directory: PathBuf,
} }
#[derive(Deserialize, Clone, Debug)]
pub struct Oidc {
pub oidc_endpoint: String
}
pub fn get_config() -> figment::Result<Config> { pub fn get_config() -> figment::Result<Config> {
CONFIG_LOCATIONS CONFIG_LOCATIONS
.iter() .iter()

View File

@ -1,20 +1,23 @@
#![warn(clippy::pedantic)] #![warn(clippy::pedantic)]
mod auth;
mod config; mod config;
mod errors; mod errors;
mod extractor_helper;
mod files; mod files;
mod routes; mod routes;
mod extractor_helper;
use axum::Router; use axum::{middleware, Router};
use axum_jwks::Jwks;
use std::env; use std::env;
use std::process::exit; use std::process::exit;
use std::str::FromStr; use std::str::FromStr;
use tracing::{debug, error, info}; use tracing::{debug, error, info};
#[derive(Clone, Debug)] #[derive(Clone)]
pub struct AppState { pub struct AppState {
config: config::Config, config: config::Config,
pub jwks: Jwks,
} }
#[tokio::main] #[tokio::main]
@ -26,10 +29,24 @@ async fn main() {
match config { match config {
Ok(config) => { Ok(config) => {
debug!("Config loaded {:?}", &config,); debug!("Config loaded {:?}", &config,);
let jwks = Jwks::from_oidc_url(
// The Authorization Server that signs the JWTs you want to consume.
&config.oidc.oidc_endpoint,
// The audience identifier for the application. This ensures that
// JWTs are intended for this application.
None,
)
.await
.unwrap();
let bind = format!("{}:{}", &config.server.bind_address, &config.server.port); let bind = format!("{}:{}", &config.server.bind_address, &config.server.port);
let state = AppState { config, jwks };
let app = Router::new() let app = Router::new()
.merge(routes::routes()) .merge(routes::routes())
.with_state(AppState { config }); .route_layer(middleware::from_fn_with_state(
state.clone(),
auth::validate_token,
))
.with_state(state);
let listener = tokio::net::TcpListener::bind(bind).await.unwrap(); let listener = tokio::net::TcpListener::bind(bind).await.unwrap();
info!("listening on {}", listener.local_addr().unwrap()); info!("listening on {}", listener.local_addr().unwrap());

View File

@ -0,0 +1,29 @@
meta {
name: Auth
type: http
seq: 5
}
get {
url:
body: none
auth: oauth2
}
auth:oauth2 {
grant_type: authorization_code
callback_url: https://localhost:1234
authorization_url: {{keycloak_host}}/realms/{{keycloak_realm}}/protocol/openid-connect/auth
access_token_url: {{keycloak_host}}/realms/{{keycloak_realm}}/protocol/openid-connect/token
client_id: casket
client_secret:
scope:
state:
pkce: false
}
script:post-response {
bru.setVar('access_token', res.body.access_token);
const atob = require('atob');
bru.setVar('user_id',JSON.parse(atob(res.body.access_token.split('.')[1])).sub)
}

View File

@ -5,11 +5,15 @@ meta {
} }
get { get {
url: http://localhost:3000/api/v1/user/test123/download?path=test/blub.txt url: {{casket_host}}/api/v1/user/{{user_id}}/download?path=test/blub.txt
body: none body: none
auth: none auth: bearer
} }
params:query { params:query {
path: test/blub.txt path: test/blub.txt
} }
auth:bearer {
token: {{access_token}}
}

View File

@ -5,12 +5,16 @@ meta {
} }
get { get {
url: http://localhost:3000/api/v1/user/test123/files?path=&nesting=5 url: {{casket_host}}/api/v1/user/{{user_id}}/files?path=&nesting=5
body: none body: none
auth: none auth: bearer
} }
params:query { params:query {
path: path:
nesting: 5 nesting: 5
} }
auth:bearer {
token: {{access_token}}
}

View File

@ -0,0 +1,15 @@
meta {
name: Invalid
type: http
seq: 6
}
get {
url: {{casket_host}}/api/v1/user//files
body: none
auth: bearer
}
auth:bearer {
token: {{access_token}}
}

View File

@ -5,9 +5,13 @@ meta {
} }
post { post {
url: http://localhost:3000/api/v1/user/test123/files url: {{casket_host}}/api/v1/user/{{user_id}}/files
body: multipartForm body: multipartForm
auth: none auth: bearer
}
auth:bearer {
token: {{access_token}}
} }
body:multipart-form { body:multipart-form {