Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,11 @@ kubernetes = ["dep:kube", "dep:k8s-openapi", "dep:schemars", "dep:either"]
notebook = ["kubernetes"]
openwebui = ["kubernetes"]
lifecycle = ["notebook"]
chemchat = []
observe = []
mcp = []
full = ["notebook", "lifecycle", "observe", "mcp", "openwebui"]
moleviewer = []
full = ["notebook", "lifecycle", "observe", "mcp", "openwebui", "chemchat"]

[dependencies]
# Async dep
Expand Down
6 changes: 5 additions & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
# Stage 1 build
FROM rust:1.90.0 AS builder
FROM rust:1.92.0 AS builder

WORKDIR /app

Expand All @@ -10,6 +10,7 @@ ARG LIFECYCLE=false
ARG OBSERVE=false
ARG MCP=false
ARG OWUI=false
ARG CHEMCHAT=false

RUN <<EOF
#!/bin/bash
Expand All @@ -29,6 +30,9 @@ fi
if [ "$OWUI" = "true" ]; then
flags+=("openwebui")
fi
if [ "$CHEMCHAT" = "true" ]; then
flags+=("chemchat")
fi
if [ ${#flags[@]} -eq 0 ]; then
echo "Building with no features..."
cargo build --release
Expand Down
2 changes: 2 additions & 0 deletions config/configurations_sample.toml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,8 @@ redirect_url = "https://open.accelerate.science/auth/callback/ibm"
[app-config]
name = "Open Accelerated Discovery"
openweb_url = "oweb.open.accelerate.science"
chemchat_url = "chemchat.open.accelerate.science"
chemchat_internal_url = "http://c.h.e.m"
moleviewer_url = "moleviewer.open.accelerate.science"
moleviewer_internal_url = "http://o.m.g.u.i"
bridge_url = "open.accelerate.science"
Expand Down
7 changes: 6 additions & 1 deletion justfile
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ LIFECYCLE_DEFAULT := "false"
OBSERVE_DEFAULT := "false"
MCP_DEFAULT := "false"
OWUI_DEFFAULT := "false"
CHEMCHAT_DEFAULT := "false"

# Consolidated build recipe that accepts a comma-separated string of features
# Usage examples:
Expand All @@ -22,6 +23,7 @@ build-features features_string="":
current_observe={{OBSERVE_DEFAULT}}
current_mcp={{MCP_DEFAULT}}
current_owui={{OWUI_DEFFAULT}}
current_chemchat={{CHEMCHAT_DEFAULT}}

# If features_string is not empty, parse it
if [[ -n "{{features_string}}" ]]; then
Expand All @@ -42,6 +44,8 @@ build-features features_string="":
current_mcp="true"
elif [[ "$trimmed_feature" == "openwebui" ]]; then
current_owui="true"
elif [[ "$trimmed_feature" == "chemchat" ]]; then
current_chemchat="true"
elif [[ -n "$trimmed_feature" ]]; then # Check if trimmed_feature is not empty
echo "Warning: Unknown feature '$trimmed_feature' in '{{features_string}}'"
fi
Expand All @@ -54,6 +58,7 @@ build-features features_string="":
cmd="$cmd --build-arg OBSERVE=${current_observe}"
cmd="$cmd --build-arg MCP=${current_mcp}"
cmd="$cmd --build-arg OWUI=${current_owui}"
cmd="$cmd --build-arg CHEMCHAT=${current_chemchat}"
cmd="$cmd ."

echo "Executing: $cmd"
Expand All @@ -69,7 +74,7 @@ build-notebook-lifecycle-observe: (build-features "notebook,lifecycle,observe")

build-notebook-lifecycle-mcp: (build-features "notebook,lifecycle,mcp")

build-full: (build-features "notebook,lifecycle,observe,mcp,openwebui")
build-full: (build-features "notebook,lifecycle,observe,mcp,openwebui,chemchat")

# --- Frontend & Minification ---
mini-js:
Expand Down
19 changes: 19 additions & 0 deletions src/config/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@ pub struct Configuration {
pub company: String,
pub oidc: HashMap<String, OIDC>,
pub observability_cred: Option<(String, String)>,
#[cfg(feature = "chemchat")]
pub chemchat_url: String,
#[cfg(feature = "chemchat")]
pub chemchat_internal_url: String,
#[cfg(feature = "openwebui")]
pub owui_namespace: String,
#[cfg(feature = "openwebui")]
Expand Down Expand Up @@ -245,6 +249,17 @@ pub fn init_once() -> Configuration {
)
};

#[cfg(feature = "chemchat")]
let (chemchat_url, chemchat_internal_url) = {
(
app_conf["chemchat_url"].as_str().unwrap().to_string(),
app_conf["chemchat_internal_url"]
.as_str()
.unwrap()
.to_string(),
)
};

let bridge_url = app_conf["bridge_url"].as_str().unwrap().to_string();

Configuration {
Expand All @@ -265,6 +280,10 @@ pub fn init_once() -> Configuration {
company,
oidc: oidc_map,
observability_cred,
#[cfg(feature = "chemchat")]
chemchat_url,
#[cfg(feature = "chemchat")]
chemchat_internal_url,
#[cfg(feature = "openwebui")]
owui_namespace,
#[cfg(feature = "openwebui")]
Expand Down
1 change: 1 addition & 0 deletions src/kube/openwebui/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -7,4 +7,5 @@ pub const OWUI: &str = "owui";
// This is a placeholder for openwebui CRD
struct OpenWebUI {
_p: PhantomData<()>,
_id: uuid::Uuid,
}
30 changes: 25 additions & 5 deletions src/web/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -110,7 +110,7 @@ pub async fn start_server(with_tls: bool) -> Result<()> {

// Lifecycle with "advisory lock"
#[cfg(all(feature = "notebook", feature = "lifecycle"))]
let handle = tokio::spawn(async move {
let notebook_lock_handle = tokio::spawn(async move {
let stream = LifecycleStream::new(notebook_lifecycle);
Medium::new(LIFECYCLE_TIME, SIGTERM_FREQ, db, stream, recv.recv()).await;
});
Expand Down Expand Up @@ -158,6 +158,23 @@ pub async fn start_server(with_tls: bool) -> Result<()> {
)
};

// #[cfg(feature = "moleviewer")]
// {
// todo!();
// }

#[cfg(feature = "chemchat")]
let app = {
use self::bridge_middleware::CookieCheck;

app.service(
web::scope("")
.guard(guard::Host(&CONFIG.chemchat_url))
.wrap(CookieCheck)
.configure(route::chemchat::config_chemchat),
)
};

let app = app.service(actix_files::Files::new("/static", "static"));

#[cfg(feature = "notebook")]
Expand Down Expand Up @@ -207,9 +224,12 @@ pub async fn start_server(with_tls: bool) -> Result<()> {
.run()
.await?;

if let Some(handler) = redirect_handle {
handler.await??;
}
if let Some(handler) = redirect_handle
&& handler.await?.is_err()
{
// error in shutdown for redirect server not a big deal, so just log and move on
tracing::error!("HTTPS redirect server shutdown failed");
};
} else {
server.bind((ip_addr, 8080))?.run().await?;
}
Expand All @@ -219,7 +239,7 @@ pub async fn start_server(with_tls: bool) -> Result<()> {

// If the lock was acquired, release it
#[cfg(all(feature = "notebook", feature = "lifecycle"))]
handle.await?;
notebook_lock_handle.await?;

Ok(())
}
Expand Down
13 changes: 10 additions & 3 deletions src/web/route/auth/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -25,20 +25,26 @@ use crate::{
mongo::DB,
},
errors::{BridgeError, Result},
web::helper::{self},
web::{
bridge_middleware::CookieCheck,
helper::{self},
},
};
#[cfg(feature = "observe")]
use crate::{config::CONFIG, logger::MESSAGE_DELIMITER};

pub use self::oauth::generate_token_with_cookie;
use self::{
deserialize::CallBackResponse,
oauth::{introspection, jwks, register_app},
oauth::{get_token, introspection, jwks, register_app},
};

mod deserialize;
mod oauth;

const NONCE_COOKIE: &str = "nonce";
pub static TOKEN_LIFETIME: usize = 60 * 60 * 24 * 30; // 24 hours
pub static COOKIE_TOKEN_LIFETIME: usize = 60 * 60 * 24; // 24 hours

#[get("/login")]
#[instrument]
Expand Down Expand Up @@ -261,6 +267,7 @@ pub fn config_auth(cfg: &mut web::ServiceConfig) {
.service(callback)
.service(introspection)
.service(register_app)
.service(jwks),
.service(jwks)
.service(web::scope("").wrap(CookieCheck).service(get_token)),
);
}
95 changes: 87 additions & 8 deletions src/web/route/auth/oauth.rs
Original file line number Diff line number Diff line change
@@ -1,29 +1,65 @@
use std::marker::PhantomData;
use std::{marker::PhantomData, str::FromStr};

use actix_web::{
HttpMessage, HttpRequest, HttpResponse, get,
http::header::{ContentType, WWW_AUTHENTICATE},
post,
web::{self, Data},
web::{self, Data, ReqData},
};
use actix_web_httpauth::extractors::{basic::BasicAuth, bearer::BearerAuth};
use mongodb::bson::doc;
use mongodb::bson::{doc, oid::ObjectId};
use regex::Regex;
use serde_json::{Value, json};
use tracing::error;

use crate::{
auth::jwt::validate_token,
config::CONFIG,
auth::jwt::{self, validate_token},
config::{AUD, CONFIG},
db::{
Database,
models::{APPS, AppPayload, Apps, GroupSubs, USER, User, UserType},
models::{
APPS, AppPayload, Apps, BridgeCookie, GROUP, Group, GroupSubs, USER, User, UserType,
},
mongo::{DB, ObjectID},
},
errors::Result,
web::helper::{self, generate_salt},
errors::{BridgeError, Result},
web::{
helper::{self, generate_salt},
route::auth::{COOKIE_TOKEN_LIFETIME, TOKEN_LIFETIME},
},
};

#[post("token")]
pub async fn get_token(
subject: Option<ReqData<BridgeCookie>>,
db: Data<&DB>,
) -> Result<HttpResponse> {
let bc = match subject {
Some(cookie_subject) => cookie_subject.into_inner(),
None => {
return helper::log_with_level!(
Err(BridgeError::UserNotFound(
"subject not passed from middleware".to_string(),
)),
error
);
}
};

let id =
ObjectId::from_str(&bc.subject).map_err(|e| BridgeError::GeneralError(e.to_string()))?;

let (token, _, _) = generate_token_with_cookie(&id, &bc, &db, COOKIE_TOKEN_LIFETIME).await?;

let payload = json!({
"access_token": token,
"token_type": "Bearer",
"expires_in": TOKEN_LIFETIME,
});

Ok(HttpResponse::Ok().json(payload))
}

#[post("introspection")]
pub async fn introspection(
basic: Option<BasicAuth>,
Expand Down Expand Up @@ -177,3 +213,46 @@ fn extract_token(payload: &str) -> Option<String> {
re?.captures(payload)
.and_then(|cap| cap.get(1).map(|m| m.as_str().to_string()))
}

pub async fn generate_token_with_cookie(
id: &ObjectId,
bc: &BridgeCookie,
db: &DB,
token_lifetime: usize,
) -> Result<(String, String, User)> {
// get information about user
let user: User = helper::log_with_level!(
db.find(
doc! {
"_id": id,
},
USER,
)
.await,
error
)?;

let scp = if user.groups.is_empty() {
vec!["".to_string()]
} else {
// get models
let group: Group = helper::log_with_level!(
db.find(
doc! {
"name": &user.groups[0]
},
GROUP,
)
.await,
error
)?;
group.subscriptions
};

// Generate bridge token
let (token, exp) = helper::log_with_level!(
jwt::get_token_and_exp(&CONFIG.encoder, token_lifetime, &bc.subject, AUD[0], scp),
error
)?;
Ok((token, exp, user))
}
Loading