Skip to content
Open
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
10 changes: 5 additions & 5 deletions Cargo.lock

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

2 changes: 1 addition & 1 deletion proto
84 changes: 71 additions & 13 deletions src/error.rs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ use axum::{
use serde_json::json;
use tonic::{Code, Status, metadata::errors::InvalidMetadataValue};

use crate::proto::CoreError;
use crate::proto::{CoreError, CoreErrorCode};

/// Core's message prefix for an OIDC MFA poll made before the browser round trip completes.
const OIDC_NOT_COMPLETED: &str = "OIDC authentication not completed";
Expand Down Expand Up @@ -45,16 +45,13 @@ pub enum ApiError {
PostureRejected(Vec<String>),
#[error("Failed to get client IP address")]
ClientIpError,
#[error("{0}")]
WithCode(Box<ApiError>, CoreErrorCode),
}

impl IntoResponse for ApiError {
fn into_response(self) -> Response {
if matches!(self, Self::OidcNotCompleted(_)) {
debug!("{self}");
} else {
error!("{self}");
}
let (status, error_message) = match self {
impl ApiError {
fn status_and_message(self) -> (StatusCode, String) {
match self {
Self::Unauthorized(msg) => (StatusCode::UNAUTHORIZED, msg),
Self::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg),
Self::PermissionDenied(msg) => (StatusCode::FORBIDDEN, msg),
Expand All @@ -67,15 +64,36 @@ impl IntoResponse for ApiError {
}
Self::NotFound(msg) => (StatusCode::NOT_FOUND, msg),
Self::PostureRejected(reasons) => (StatusCode::FORBIDDEN, reasons.join(", ")),
Self::WithCode(inner, _) => inner.status_and_message(),
_ => (
StatusCode::INTERNAL_SERVER_ERROR,
"Internal server error".to_string(),
),
};
}
}
}

let body = Json(json!({"error": error_message}));
/// The web UI matches on the lowercase name without the enum prefix, e.g. `oidc_account_mismatch`.
fn code_name(code: CoreErrorCode) -> String {
code.as_str_name()
.trim_start_matches("CORE_ERROR_CODE_")
.to_ascii_lowercase()
}

impl IntoResponse for ApiError {
fn into_response(self) -> Response {
if matches!(self, Self::OidcNotCompleted(_)) {
debug!("{self}");
} else {
error!("{self}");
}
let code = match &self {
Self::WithCode(_, code) => Some(code_name(*code)),
_ => None,
};
let (status, error_message) = self.status_and_message();

(status, body).into_response()
(status, Json(json!({"error": error_message, "code": code}))).into_response()
}
}

Expand All @@ -92,9 +110,10 @@ impl From<CoreError> for ApiError {
if is_oidc_not_completed(&core_error) {
return ApiError::OidcNotCompleted(core_error.message);
}
let code = core_error.code();
// convert to tonic::Status first
let status = Status::new(Code::from(core_error.status_code), core_error.message);
match status.code() {
let error = match status.code() {
Code::Unauthenticated => ApiError::Unauthorized(status.message().to_string()),
Code::InvalidArgument => ApiError::BadRequest(status.message().to_string()),
Code::PermissionDenied => ApiError::PermissionDenied(status.message().to_string()),
Expand All @@ -106,6 +125,10 @@ impl From<CoreError> for ApiError {
Code::Unavailable => ApiError::CoreTimeout,
Code::NotFound => ApiError::NotFound(status.to_string()),
_ => ApiError::Unexpected(status.to_string()),
};
match code {
CoreErrorCode::Unspecified => error,
code => ApiError::WithCode(Box::new(error), code),
}
}
}
Expand All @@ -118,10 +141,45 @@ mod tests {
CoreError {
status_code: Code::FailedPrecondition as i32,
message: message.into(),
..Default::default()
}
.into()
}

async fn response_json(error: ApiError) -> (StatusCode, serde_json::Value) {
let response = error.into_response();
let status = response.status();
let bytes = axum::body::to_bytes(response.into_body(), usize::MAX)
.await
.unwrap();
(status, serde_json::from_slice(&bytes).unwrap())
}

#[tokio::test]
async fn test_core_error_code_reaches_response_body() {
let coded = CoreError {
status_code: Code::Unauthenticated as i32,
message: "unauthorized".into(),
code: CoreErrorCode::OidcAccountMismatch.into(),
};
let (status, body) = response_json(coded.into()).await;
assert_eq!(status, StatusCode::UNAUTHORIZED);
assert_eq!(
body,
json!({"error": "unauthorized", "code": "oidc_account_mismatch"})
);

// Older Core versions send no code.
let uncoded = CoreError {
status_code: Code::InvalidArgument as i32,
message: "invalid state data".into(),
..Default::default()
};
let (status, body) = response_json(uncoded.into()).await;
assert_eq!(status, StatusCode::BAD_REQUEST);
assert_eq!(body, json!({"error": "invalid state data", "code": null}));
}

#[test]
fn test_oidc_not_completed_maps_to_own_variant() {
// Core's MFA config and client MFA wordings.
Expand Down
2 changes: 2 additions & 0 deletions src/tests/mfa_config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,7 @@ async fn test_mfa_config_core_error_maps_to_http_status() {
core_response::Payload::CoreError(CoreError {
status_code: tonic::Code::Unauthenticated as i32,
message: "invalid token".into(),
..Default::default()
})
});

Expand All @@ -186,6 +187,7 @@ async fn test_mfa_config_core_error_maps_to_http_status() {
core_response::Payload::CoreError(CoreError {
status_code: tonic::Code::FailedPrecondition as i32,
message: "OIDC authentication not completed".into(),
..Default::default()
})
});

Expand Down
2 changes: 1 addition & 1 deletion src/tests/mfa_flow.rs
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,7 @@ async fn test_start_maps_posture_rejection_to_forbidden() {
let (status, body) = post_json(&app, "/api/v1/mfa-flow/start", &request).await;

assert_eq!(status, StatusCode::FORBIDDEN);
assert_eq!(body, json!({"error": "OS version"}));
assert_eq!(body, json!({"error": "OS version", "code": null}));
}

#[tokio::test]
Expand Down
1 change: 1 addition & 0 deletions src/tests/support.rs
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,7 @@ where
core_response::Payload::CoreError(CoreError {
status_code: tonic::Code::Internal as i32,
message: format!("fake Core panicked: {message}"),
..Default::default()
})
});
responder.resolve_test_response(request.id, response);
Expand Down
6 changes: 5 additions & 1 deletion web/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -104,9 +104,13 @@
"openid_mfa_redirect_error_title": "Authentication Error",
"openid_mfa_redirect_error_message": "No token provided in the URL. Please ensure you have a valid token to proceed with OpenID authentication.",
"openid_mfa_redirect_error_missing_args": "Missing code or state in the callback's URL. The provider might not be configured correctly.",
"openid_mfa_callback_error": "We couldn't verify your sign-in. Make sure you signed in with the account this Defguard VPN Client belongs to, then try connecting again.",
"api_error_oidc_account_mismatch": "You signed in with a different account than the one this Defguard VPN Client belongs to. Sign out of your identity provider, then try connecting again with the correct account.",
"api_error_oidc_verification_failed": "We couldn't verify your sign-in with your identity provider. Make sure you use the account linked to your Defguard user, then try connecting again.",
"api_error_mfa_session_expired": "This sign-in request has expired or was already used. Start connecting again from the Defguard VPN Client.",
"openid_mfa_complete_title": "Authentication Completed",
"openid_mfa_complete_subtitle": "You have been successfully authenticated. Please close this window and get back to the Defguard VPN Client",
"openid_generic_error": "OpenID authentication error",
"openid_generic_error": "Sign-in with your identity provider failed. Try again, or contact your administrator if the problem continues.",
"open_desktop_title": "Open the desktop app to continue",
"open_desktop_description": "We tried to open the desktop app automatically, but it didn't respond. This can happen if the browser blocks the request or the app didn't start in time.",
"open_desktop_button": "Open desktop app",
Expand Down
83 changes: 83 additions & 0 deletions web/src/routes/openid/callback.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { Route } from './callback';

const mocks = vi.hoisted(() => ({
callback: vi.fn(),
setError: vi.fn(),
}));

vi.mock('../../shared/api/api', () => ({
api: {
openId: {
enrollmentCallback: {
callbackFn: mocks.callback,
},
},
enrollment: {
start: {
callbackFn: vi.fn(),
},
},
},
}));
vi.mock('../../paraglide/messages', () => ({
m: {
openid_generic_error: () => 'Sign-in failed',
},
}));
vi.mock('../../shared/hooks/useEnrollmentStore', () => ({
useEnrollmentStore: {
setState: vi.fn(),
},
}));
vi.mock('../../shared/hooks/useOpenIdStore', () => ({
useOpenidStore: {
setState: mocks.setError,
},
}));

const beforeLoad = () =>
(
Route.options.beforeLoad as (args: {
search: { code: string; state: string };
}) => Promise<unknown>
)({
search: { code: 'code', state: 'state' },
});

describe('OpenID enrollment callback route', () => {
beforeEach(() => {
vi.clearAllMocks();
});

it('shows a friendly error with the provider error as details', async () => {
mocks.callback.mockRejectedValue({
isAxiosError: true,
response: {
data: {
error: 'invalid_client: The provided client secret is invalid.',
},
},
});

await expect(beforeLoad()).rejects.toMatchObject({
options: { replace: true, to: '/openid/error' },
});
expect(mocks.setError).toHaveBeenCalledWith({
error: 'Sign-in failed',
details: 'invalid_client: The provided client secret is invalid.',
});
});

it('shows no details when the failure has no error text', async () => {
mocks.callback.mockRejectedValue(new Error('Network Error'));

await expect(beforeLoad()).rejects.toMatchObject({
options: { replace: true, to: '/openid/error' },
});
expect(mocks.setError).toHaveBeenCalledWith({
error: 'Sign-in failed',
details: undefined,
});
});
});
4 changes: 3 additions & 1 deletion web/src/routes/openid/callback.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { createFileRoute, redirect } from '@tanstack/react-router';
import z from 'zod';
import { m } from '../../paraglide/messages';
import { api } from '../../shared/api/api';
import { getApiErrorDetails } from '../../shared/api/apiErrorMessages';
import { useEnrollmentStore } from '../../shared/hooks/useEnrollmentStore';
import { useOpenidStore } from '../../shared/hooks/useOpenIdStore';

Expand Down Expand Up @@ -29,7 +30,8 @@ export const Route = createFileRoute('/openid/callback')({
})
.catch((e) => {
useOpenidStore.setState({
error: e.response?.data?.error ?? m.openid_generic_error(),
error: m.openid_generic_error(),
details: getApiErrorDetails(e),
});
throw redirect({ to: '/openid/error', replace: true });
});
Expand Down
2 changes: 2 additions & 0 deletions web/src/routes/openid/error.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,13 @@ function RouteComponent() {
const openIdError = useOpenidStore(
(s) => s.error ?? m.openid_mfa_redirect_error_message(),
);
const details = useOpenidStore((s) => s.details);

return (
<PageInfo
title={m.openid_mfa_redirect_error_title()}
subtitle={openIdError}
details={details}
icon="disabled"
/>
);
Expand Down
Loading
Loading