Skip to content

Commit 707761e

Browse files
authored
Use configured external OIDC Provider for 2FA in client (#119)
* oidc mfa for client * formatting * update protobufs
1 parent 50f4bc3 commit 707761e

20 files changed

Lines changed: 667 additions & 23 deletions

File tree

proto

Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
use axum::{extract::State, Json};
2+
use axum_extra::extract::{cookie::Cookie, PrivateCookieJar};
3+
use tracing::{debug, error, info, warn};
4+
5+
use crate::{
6+
enterprise::handlers::openid_login::{
7+
AuthenticationResponse, FlowType, CSRF_COOKIE_NAME, NONCE_COOKIE_NAME,
8+
},
9+
error::ApiError,
10+
handlers::get_core_response,
11+
http::AppState,
12+
proto::{core_request, core_response, ClientMfaOidcAuthenticateRequest, DeviceInfo},
13+
};
14+
15+
#[instrument(level = "debug", skip(state))]
16+
pub(super) async fn mfa_auth_callback(
17+
State(state): State<AppState>,
18+
device_info: DeviceInfo,
19+
mut private_cookies: PrivateCookieJar,
20+
Json(payload): Json<AuthenticationResponse>,
21+
) -> Result<PrivateCookieJar, ApiError> {
22+
info!("Processing MFA authentication callback");
23+
debug!(
24+
"Received payload: state={}, flow_type={}",
25+
payload.state, payload.flow_type
26+
);
27+
28+
let flow_type = payload.flow_type.parse::<FlowType>().map_err(|err| {
29+
warn!("Failed to parse flow type '{}': {err:?}", payload.flow_type);
30+
ApiError::BadRequest("Invalid flow type".into())
31+
})?;
32+
33+
if flow_type != FlowType::Mfa {
34+
warn!("Invalid flow type for MFA callback: {flow_type:?}");
35+
return Err(ApiError::BadRequest(
36+
"Invalid flow type for MFA callback".into(),
37+
));
38+
}
39+
40+
debug!("Flow type validation passed: {flow_type:?}");
41+
42+
let nonce = private_cookies
43+
.get(NONCE_COOKIE_NAME)
44+
.ok_or_else(|| {
45+
warn!("Nonce cookie not found in request");
46+
ApiError::Unauthorized("Nonce cookie not found".into())
47+
})?
48+
.value_trimmed()
49+
.to_string();
50+
51+
let csrf = private_cookies
52+
.get(CSRF_COOKIE_NAME)
53+
.ok_or_else(|| {
54+
warn!("CSRF cookie not found in request");
55+
ApiError::Unauthorized("CSRF cookie not found".into())
56+
})?
57+
.value_trimmed()
58+
.to_string();
59+
60+
debug!("Retrieved cookies successfully");
61+
62+
if payload.state != csrf {
63+
warn!(
64+
"CSRF token mismatch: expected={csrf}, received={}",
65+
payload.state
66+
);
67+
return Err(ApiError::Unauthorized("CSRF token mismatch".into()));
68+
}
69+
70+
debug!("CSRF token validation passed");
71+
72+
private_cookies = private_cookies
73+
.remove(Cookie::from(NONCE_COOKIE_NAME))
74+
.remove(Cookie::from(CSRF_COOKIE_NAME));
75+
76+
debug!("Removed security cookies");
77+
78+
let request = ClientMfaOidcAuthenticateRequest {
79+
code: payload.code,
80+
nonce,
81+
callback_url: state.callback_url(flow_type).to_string(),
82+
state: payload.state,
83+
};
84+
85+
debug!("Sending MFA OIDC authenticate request to core service");
86+
87+
let rx = state.grpc_server.send(
88+
core_request::Payload::ClientMfaOidcAuthenticate(request),
89+
device_info,
90+
)?;
91+
92+
let payload = get_core_response(rx).await?;
93+
94+
if let core_response::Payload::Empty(()) = payload {
95+
info!("MFA authentication callback completed successfully");
96+
Ok(private_cookies)
97+
} else {
98+
error!("Received invalid gRPC response type during handling the MFA OpenID authentication callback: {payload:#?}");
99+
Err(ApiError::InvalidResponseType)
100+
}
101+
}

src/enterprise/handlers/mod.rs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1 +1,2 @@
1+
pub mod desktop_client_mfa;
12
pub mod openid_login;

src/enterprise/handlers/openid_login.rs

Lines changed: 57 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,4 @@
1-
use axum::{
2-
extract::State,
3-
routing::{get, post},
4-
Json, Router,
5-
};
1+
use axum::{extract::State, routing::post, Json, Router};
62
use axum_extra::extract::{
73
cookie::{Cookie, SameSite},
84
PrivateCookieJar,
@@ -11,6 +7,7 @@ use serde::{Deserialize, Serialize};
117
use time::Duration;
128

139
use crate::{
10+
enterprise::handlers::desktop_client_mfa::mfa_auth_callback,
1411
error::ApiError,
1512
handlers::get_core_response,
1613
http::AppState,
@@ -21,13 +18,14 @@ use crate::{
2118
};
2219

2320
const COOKIE_MAX_AGE: Duration = Duration::days(1);
24-
static CSRF_COOKIE_NAME: &str = "csrf_proxy";
25-
static NONCE_COOKIE_NAME: &str = "nonce_proxy";
21+
pub(super) static CSRF_COOKIE_NAME: &str = "csrf_proxy";
22+
pub(super) static NONCE_COOKIE_NAME: &str = "nonce_proxy";
2623

2724
pub(crate) fn router() -> Router<AppState> {
2825
Router::new()
29-
.route("/auth_info", get(auth_info))
26+
.route("/auth_info", post(auth_info))
3027
.route("/callback", post(auth_callback))
28+
.route("/callback/mfa", post(mfa_auth_callback))
3129
}
3230

3331
#[derive(Serialize)]
@@ -46,17 +44,49 @@ impl AuthInfo {
4644
}
4745
}
4846

47+
#[derive(Deserialize, Debug, PartialEq, Eq)]
48+
pub(crate) enum FlowType {
49+
Enrollment,
50+
Mfa,
51+
}
52+
53+
impl std::str::FromStr for FlowType {
54+
type Err = ();
55+
56+
fn from_str(s: &str) -> Result<Self, Self::Err> {
57+
match s.to_lowercase().as_str() {
58+
"enrollment" => Ok(FlowType::Enrollment),
59+
"mfa" => Ok(FlowType::Mfa),
60+
_ => Err(()),
61+
}
62+
}
63+
}
64+
65+
#[derive(Deserialize, Debug)]
66+
struct RequestData {
67+
state: Option<String>,
68+
#[serde(rename = "type")]
69+
flow_type: String,
70+
}
71+
4972
/// Request external OAuth2/OpenID provider details from Defguard Core.
5073
#[instrument(level = "debug", skip(state))]
5174
async fn auth_info(
5275
State(state): State<AppState>,
5376
device_info: DeviceInfo,
5477
private_cookies: PrivateCookieJar,
78+
Json(request_data): Json<RequestData>,
5579
) -> Result<(PrivateCookieJar, Json<AuthInfo>), ApiError> {
5680
debug!("Getting auth info for OAuth2/OpenID login");
5781

82+
let flow_type = request_data
83+
.flow_type
84+
.parse::<FlowType>()
85+
.map_err(|_| ApiError::BadRequest("Invalid flow type".into()))?;
86+
5887
let request = AuthInfoRequest {
59-
redirect_url: state.callback_url().to_string(),
88+
redirect_url: state.callback_url(flow_type).to_string(),
89+
state: request_data.state,
6090
};
6191

6292
let rx = state
@@ -93,9 +123,11 @@ async fn auth_info(
93123
}
94124

95125
#[derive(Debug, Deserialize)]
96-
pub struct AuthenticationResponse {
97-
code: String,
98-
state: String,
126+
pub(super) struct AuthenticationResponse {
127+
pub(super) code: String,
128+
pub(super) state: String,
129+
#[serde(rename = "type")]
130+
pub(super) flow_type: String,
99131
}
100132

101133
#[derive(Serialize)]
@@ -111,6 +143,17 @@ async fn auth_callback(
111143
mut private_cookies: PrivateCookieJar,
112144
Json(payload): Json<AuthenticationResponse>,
113145
) -> Result<(PrivateCookieJar, Json<CallbackResponseData>), ApiError> {
146+
let flow_type = payload
147+
.flow_type
148+
.parse::<FlowType>()
149+
.map_err(|_| ApiError::BadRequest("Invalid flow type".into()))?;
150+
151+
if flow_type != FlowType::Enrollment {
152+
return Err(ApiError::BadRequest(
153+
"Invalid flow type for OpenID enrollment callback".into(),
154+
));
155+
}
156+
114157
let nonce = private_cookies
115158
.get(NONCE_COOKIE_NAME)
116159
.ok_or(ApiError::Unauthorized("Nonce cookie not found".into()))?
@@ -133,13 +176,14 @@ async fn auth_callback(
133176
let request = AuthCallbackRequest {
134177
code: payload.code,
135178
nonce,
136-
callback_url: state.callback_url().to_string(),
179+
callback_url: state.callback_url(flow_type).to_string(),
137180
};
138181

139182
let rx = state
140183
.grpc_server
141184
.send(core_request::Payload::AuthCallback(request), device_info)?;
142185
let payload = get_core_response(rx).await?;
186+
143187
if let core_response::Payload::AuthCallback(AuthCallbackResponse { url, token }) = payload {
144188
debug!("Received auth callback response {url:?} {token:?}");
145189
Ok((private_cookies, Json(CallbackResponseData { url, token })))

src/error.rs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ pub enum ApiError {
2626
PermissionDenied(String),
2727
#[error("Enterprise not enabled")]
2828
EnterpriseNotEnabled,
29+
#[error("Precondition required: {0}")]
30+
PreconditionRequired(String),
2931
}
3032

3133
impl IntoResponse for ApiError {
@@ -39,6 +41,7 @@ impl IntoResponse for ApiError {
3941
StatusCode::PAYMENT_REQUIRED,
4042
"Enterprise features are not enabled".to_string(),
4143
),
44+
Self::PreconditionRequired(msg) => (StatusCode::PRECONDITION_REQUIRED, msg),
4245
_ => (
4346
StatusCode::INTERNAL_SERVER_ERROR,
4447
"Internal server error".to_string(),
@@ -64,8 +67,9 @@ impl From<CoreError> for ApiError {
6467
Code::FailedPrecondition => match status.message().to_lowercase().as_str() {
6568
// TODO: find a better way than matching on the error message
6669
"no valid license" => ApiError::EnterpriseNotEnabled,
67-
_ => ApiError::Unexpected(status.to_string()),
70+
_ => ApiError::PreconditionRequired(status.message().to_string()),
6871
},
72+
Code::Unavailable => ApiError::CoreTimeout,
6973
_ => ApiError::Unexpected(status.to_string()),
7074
}
7175
}

src/http.rs

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ use url::Url;
2828
use crate::{
2929
assets::{index, svg, web_asset},
3030
config::Config,
31-
enterprise::handlers::openid_login,
31+
enterprise::handlers::openid_login::{self, FlowType},
3232
error::ApiError,
3333
grpc::ProxyServer,
3434
handlers::{desktop_client_mfa, enrollment, password_reset, polling},
@@ -49,11 +49,14 @@ pub(crate) struct AppState {
4949
impl AppState {
5050
/// Returns configured URL with "auth/callback" appended to the path.
5151
#[must_use]
52-
pub(crate) fn callback_url(&self) -> Url {
52+
pub(crate) fn callback_url(&self, flow_type: FlowType) -> Url {
5353
let mut url = self.url.clone();
5454
// Append "/openid/callback" to the URL.
5555
if let Ok(mut path_segments) = url.path_segments_mut() {
56-
path_segments.extend(&["openid", "callback"]);
56+
match flow_type {
57+
FlowType::Enrollment => path_segments.extend(&["openid", "callback"]),
58+
FlowType::Mfa => path_segments.extend(&["openid", "mfa", "callback"]),
59+
};
5760
}
5861
url
5962
}

web/src/components/App/App.tsx

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,8 @@ import { detectLocale } from '../../i18n/i18n-util';
1818
import { loadLocaleAsync } from '../../i18n/i18n-util.async';
1919
import { EnrollmentPage } from '../../pages/enrollment/EnrollmentPage';
2020
import { MainPage } from '../../pages/main/MainPage';
21+
import { OpenIdMfaCallbackPage } from '../../pages/mfa/OpenIDCallback';
22+
import { OpenIdMfaPage } from '../../pages/mfa/OpenIDRedirect';
2123
import { OpenIDCallbackPage } from '../../pages/openidCallback/OpenIDCallback';
2224
import { PasswordResetPage } from '../../pages/passwordReset/PasswordResetPage';
2325
import { SessionTimeoutPage } from '../../pages/sessionTimeout/SessionTimeoutPage';
@@ -57,6 +59,14 @@ const router = createBrowserRouter([
5759
path: routes.openidCallback,
5860
element: <OpenIDCallbackPage />,
5961
},
62+
{
63+
path: routes.openidMfa,
64+
element: <OpenIdMfaPage />,
65+
},
66+
{
67+
path: routes.openidMfaCallback,
68+
element: <OpenIdMfaCallbackPage />,
69+
},
6070
{
6171
path: '/*',
6272
element: <Navigate to="/" replace />,

web/src/i18n/en/index.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -321,6 +321,26 @@ If you want to disengage your VPN connection, simply press "deactivate".
321321
},
322322
},
323323
},
324+
openidMfaCallback: {
325+
error: {
326+
title: 'Authentication Error',
327+
message:
328+
'There was an error during authentication with the provider. Please go back to the **Defguard VPN Client** and repeat the process.',
329+
detailsTitle: 'Error Details',
330+
},
331+
success: {
332+
title: 'Authentication Completed',
333+
message:
334+
'You have been successfully authenticated. Please close this window and get back to the **Defguard VPN Client**.',
335+
},
336+
},
337+
openidMfaRedirect: {
338+
error: {
339+
title: 'Authentication Error',
340+
message:
341+
'No token provided in the URL. Please ensure you have a valid token to proceed with OpenID authentication.',
342+
},
343+
},
324344
},
325345
} satisfies BaseTranslation;
326346

0 commit comments

Comments
 (0)