oidc login #5

Merged
nif merged 5 commits from feature/login into main 2025-01-22 19:40:01 +01:00
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 {