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]
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-util = { version = "0.7.13", features = ["io"] }
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 server: Server,
pub files: Files,
pub oidc: Oidc
}
#[derive(Deserialize, Clone, Debug)]
@ -21,6 +22,11 @@ pub struct Files {
pub directory: PathBuf,
}
#[derive(Deserialize, Clone, Debug)]
pub struct Oidc {
pub oidc_endpoint: String
}
pub fn get_config() -> figment::Result<Config> {
CONFIG_LOCATIONS
.iter()

View File

@ -1,20 +1,23 @@
#![warn(clippy::pedantic)]
mod auth;
mod config;
mod errors;
mod extractor_helper;
mod files;
mod routes;
mod extractor_helper;
use axum::Router;
use axum::{middleware, Router};
use axum_jwks::Jwks;
use std::env;
use std::process::exit;
use std::str::FromStr;
use tracing::{debug, error, info};
#[derive(Clone, Debug)]
#[derive(Clone)]
pub struct AppState {
config: config::Config,
pub jwks: Jwks,
}
#[tokio::main]
@ -26,10 +29,24 @@ async fn main() {
match config {
Ok(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 state = AppState { config, jwks };
let app = Router::new()
.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();
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 {
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
auth: none
auth: bearer
}
params:query {
path: test/blub.txt
}
auth:bearer {
token: {{access_token}}
}

View File

@ -5,12 +5,16 @@ meta {
}
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
auth: none
auth: bearer
}
params:query {
path:
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 {
url: http://localhost:3000/api/v1/user/test123/files
url: {{casket_host}}/api/v1/user/{{user_id}}/files
body: multipartForm
auth: none
auth: bearer
}
auth:bearer {
token: {{access_token}}
}
body:multipart-form {