Skip to content
Open
Show file tree
Hide file tree
Changes from 2 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
35 changes: 0 additions & 35 deletions backend/Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

12 changes: 9 additions & 3 deletions backend/auth-core/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -15,20 +15,26 @@ publish = false

[dependencies]
anyhow = { workspace = true, features = ["backtrace"] }
async-trait = {workspace = true }
async-trait = { workspace = true }
axum = { workspace = true, features = ["macros"] }
axum-extra = { version = "0.12.6", features = ["typed-header"] }
base64 = "0.22.1"
chrono = { workspace = true }
http-body-util = "0.1.3"
jsonwebtoken = "11.1.0"
migration = { version = "0.1.0", path = "migration" }
oauth2 = "5.0.0"
openidconnect = { workspace = true, features = ["timing-resistant-secret-traits"] }
openidconnect = { workspace = true, features = [
"timing-resistant-secret-traits",
] }
reqwest.workspace = true
rstest = "0.28.0"
rustls = { workspace = true, features = ["aws-lc-rs"] }
sea-orm = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
serde_yaml = { workspace = true }
sodiumoxide = "0.2.7"
thiserror = { workspace = true }
testcontainers = { workspace = true }
tokio = { workspace = true, features = ["full"] }
tracing = { workspace = true }
2 changes: 2 additions & 0 deletions backend/auth-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ pub mod healthcheck;
pub mod middleware;
pub mod oidc;
pub mod request;
pub mod test_oidc_server;
pub mod validate_token;
pub use async_trait;
pub use base64;
pub use oauth2;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,8 @@ use std::time::Duration;

use anyhow::Context;
use axum_extra::headers::Authorization;
use testcontainers::core::wait::HttpWaitStrategy;
use testcontainers::core::WaitFor;
use testcontainers::core::wait::HttpWaitStrategy;
use testcontainers::runners::AsyncRunner;
use testcontainers::{ContainerAsync, GenericImage, ImageExt};

Expand Down
Original file line number Diff line number Diff line change
@@ -1,20 +1,20 @@
use axum::http::Uri;
use axum_extra::headers::{authorization::Bearer, Authorization};
use jsonwebtoken::{decode, decode_header, jwk::JwkSet, Algorithm, DecodingKey, Validation};
use axum_extra::headers::{Authorization, authorization::Bearer};
use jsonwebtoken::{Algorithm, DecodingKey, Validation, decode, decode_header, jwk::JwkSet};
use openidconnect::{
core::CoreClient, AccessToken, ClientId, ClientSecret, EndpointMaybeSet, EndpointNotSet,
EndpointSet, IntrospectionUrl, IssuerUrl, TokenIntrospectionResponse,
AccessToken, ClientId, ClientSecret, EndpointMaybeSet, EndpointNotSet, EndpointSet,
IntrospectionUrl, IssuerUrl, TokenIntrospectionResponse, core::CoreClient,
};

use serde::{Deserialize, Serialize};

use openidconnect::{
AdditionalProviderMetadata, ProviderMetadata,
core::{
CoreAuthDisplay, CoreClaimName, CoreClaimType, CoreClientAuthMethod, CoreGrantType,
CoreJsonWebKey, CoreJweContentEncryptionAlgorithm, CoreJweKeyManagementAlgorithm,
CoreResponseMode, CoreResponseType, CoreSubjectIdentifierType,
},
AdditionalProviderMetadata, ProviderMetadata,
};
use serde_json::Value;

Expand Down
13 changes: 5 additions & 8 deletions backend/auth-gateway/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -11,30 +11,27 @@ path = "src/bin/keygen.rs"
publish = false

[dependencies]
async-trait = {workspace = true }
async-trait = { workspace = true }
auth-core = { path = "../auth-core" }
anyhow = { workspace = true, features = ["backtrace"] }
axum = { workspace = true, features = ["macros"] }
axum-reverse-proxy = { workspace = true, features=["full"] }
bytes = "1.11.0"
axum-reverse-proxy = { workspace = true, features = ["full"] }
chrono.workspace = true
clap.workspace = true
dotenvy.workspace = true
hyper = "1.8.1"
moka.workspace = true
serde.workspace = true
serde_json.workspace = true
thiserror.workspace = true
tokio = { workspace = true, features = ["full"] }
tower-sessions = "0.14.0"
tower-http = { workspace = true }
regex = { workspace = true }
reqwest = { workspace = true }
tower-sessions-cookie-store = "0.5.2"
tower = { workspace = true }
tokio-tungstenite = { workspace = true, features = ["rustls-tls-native-roots"] }
tracing = { workspace = true }
tracing-subscriber = { workspace = true, features = ["env-filter"] }
jsonwebtoken = { version = "11.1.0", features = ["aws_lc_rs"] }
openidconnect.workspace = true
axum-extra = { version = "0.12.6", features = ["typed-header"] }

[dev-dependencies]
axum-test = { workspace = true }
Expand Down
161 changes: 161 additions & 0 deletions backend/auth-gateway/src/client_login.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
use crate::auth_session_data::TokenSessionData;
use crate::state::AppState;

use auth_core::database::write_token_to_database;
use auth_core::oauth2::{ClientId, ClientSecret, Scope};
use auth_core::oidc::OidcClient;
use auth_core::openidconnect::core::CoreProviderMetadata;
use auth_core::openidconnect::{IssuerUrl, SubjectIdentifier, TokenResponse};
use auth_core::validate_token::{ValidatedAuthToken, ValidationMethod};
use axum_extra::TypedHeader;
use axum_extra::headers::Authorization;
use axum_extra::headers::authorization::Bearer;
use jsonwebtoken::dangerous::insecure_decode;

use axum::response::{IntoResponse, Response};
use axum::{Json, extract::State};

use hyper::StatusCode;
use serde::{Deserialize, Serialize};
use std::sync::Arc;

#[derive(Debug, Deserialize, Serialize)]
pub struct IdTokenClaims {
iss: String,
preferred_username: String,
sub: String,
name: Option<String>,
fedid: Option<String>,
}

#[derive(Debug, Deserialize, Serialize)]
pub struct ClientCredentials {
pub client_id: String,
pub client_secret: String,
}

pub struct AppError {
status: StatusCode,
message: String,
}
impl IntoResponse for AppError {
fn into_response(self) -> Response {
(self.status, self.message).into_response()
}
}

impl AppError {
fn new(status: StatusCode, message: impl Into<String>) -> Self {
Self {
status,
message: message.into(),
}
}

fn new_500(message: impl Into<String>) -> Self {
Self::new(StatusCode::INTERNAL_SERVER_ERROR, message)
}
}

pub async fn client_credential_login(
State(state): State<Arc<AppState>>,
TypedHeader(auth_header): TypedHeader<Authorization<Bearer>>,
Json(client_creds): Json<ClientCredentials>,
) -> Result<StatusCode, AppError> {
let http_client = reqwest::ClientBuilder::new()
.redirect(reqwest::redirect::Policy::none())
.build()
.map_err(|e| {
AppError::new(
e.status().unwrap_or(StatusCode::INTERNAL_SERVER_ERROR),
e.to_string(),
)
})?;

let provider_metadata =
CoreProviderMetadata::discover_async(state.oidc_provider_url.clone(), &http_client)
.await
.map_err(|e| AppError {
status: StatusCode::SERVICE_UNAVAILABLE,
message: format!("OIDC discovery failed: {e}"),
})?;

let auth_token = state
.token_validator
.validate_token(Some(auth_header), ValidationMethod::Jwt)
.await;

match auth_token {
ValidatedAuthToken::Valid(_) => {}
ValidatedAuthToken::Invalid => {
return Err(AppError::new(
StatusCode::UNAUTHORIZED,
"Invalid token".to_string(),
));
}
ValidatedAuthToken::Missing => {
return Err(AppError::new(
StatusCode::UNAUTHORIZED,
"Missing auth token".to_string(),
));
}
ValidatedAuthToken::Failed(e) => {
return Err(AppError::new_500(e));
}
};

let client = OidcClient::from_provider_metadata(

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This requires the client to send their secret to auth-gateway.

I would move this logic to the CLI. Let the CLI acquire the refresh_token locally on the users machine. Then send the refresh_token to the auth-gateway server. This means that we never need to see their client secret.

Auth gateway should just validate that the access token used for the request is valid.

provider_metadata,
ClientId::new(client_creds.client_id),
Some(ClientSecret::new(client_creds.client_secret)),
);
let token_response = match client
.exchange_client_credentials()
.unwrap_or(Err(AppError::new_500(
"Client credentials flow not supported",
))?)
.add_scope(Scope::new("offline_access".to_string()))
.add_scope(Scope::new("openid".to_string()))
.add_scope(Scope::new("fedid".to_string()))
.request_async(&http_client)
.await
{
Ok(resp) => resp,
Err(e) => {
tracing::warn!("Client credential exchange failed: {e}");
return Err(AppError::new(
StatusCode::UNAUTHORIZED,
"Invalid client credentials",
));
}
};

let id_token = token_response.id_token().ok_or(AppError::new(
StatusCode::BAD_GATEWAY,
"No ID token was obtained for these client credentials".to_string(),
))?;

let claims: IdTokenClaims = insecure_decode::<IdTokenClaims>(&id_token.to_string())
.map_err(|_| AppError::new_500("Unexpected ID token format".to_string()))?
.claims;

let token_data = TokenSessionData::from_token_response(
&token_response,
IssuerUrl::new(claims.iss)
.map_err(|_| AppError::new_500("Could not convert iss claim to URL".to_string()))?,
SubjectIdentifier::new(claims.sub),

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If you implement the above comment (get refresh token on the client side, send the refresh_token)

Use the subject from the access_token sent with the graph QL mutation. That guarantees we are updating the database entry that belongs to the user/client that made the query.

claims.name,
Some(claims.preferred_username),
claims.fedid,
)
.map_err(|_| AppError::new_500("Failed to construct token session data"))?;

write_token_to_database(&state.database_connection, &token_data, &state.public_key)
.await
.map_err(|e| {
tracing::error!("Database write failed: {:?}", e);
AppError::new_500("Failed to write token to database")
})?;

Ok(StatusCode::OK)
}
5 changes: 5 additions & 0 deletions backend/auth-gateway/src/main.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
mod auth_session_data;
mod callback;
mod client_login;
mod config;
mod login;
mod state;
Expand Down Expand Up @@ -159,6 +160,10 @@ fn create_router(
// it any other routes should be wrapped by CorsLayer above if not having a similar bearer
// token authentication to `/auth/status`
.route("/auth/status", get(status).layer(status_cors))
.route(
"/auth/client-creds",
post(client_login::client_credential_login),
)
.with_state(state)
}

Expand Down
Loading