diff --git a/console/src/api/apiClient.ts b/console/src/api/apiClient.ts index e87ae12179f5a..85f644579e23d 100644 --- a/console/src/api/apiClient.ts +++ b/console/src/api/apiClient.ts @@ -20,6 +20,7 @@ import { } from "~/config/AppConfig"; import { ContextHolder } from "~/external-library-wrappers/frontegg"; import { MzOidcUserManager } from "~/external-library-wrappers/oidc"; +import { formatLoginErrorForOIDC, readAuthErrorDetail } from "~/utils/oidcAuth"; import { logoutAndRedirect } from "./materialize/auth"; import { @@ -177,9 +178,17 @@ export class SelfManagedApiClient #mzApiWithAuthRedirect = async (...req: Parameters) => { const response = await globalFetch(...req); - // If the user is not authenticated, we redirect to the login page. if (response.status === 401) { - await logoutAndRedirect({ apiClient: this }); + const reason = + this.authMode === "Oidc" + ? formatLoginErrorForOIDC(req[0]) + : "session_expired"; + const detail = + reason === "auth_rejected" + ? await readAuthErrorDetail(response) + : undefined; + const message = reason ? { reason, detail } : undefined; + await logoutAndRedirect({ apiClient: this, message }); } return response; }; diff --git a/console/src/api/materialize/auth.ts b/console/src/api/materialize/auth.ts index 839e18e61fb89..b80ed2d2c58bd 100644 --- a/console/src/api/materialize/auth.ts +++ b/console/src/api/materialize/auth.ts @@ -8,6 +8,10 @@ // by the Apache License, Version 2.0. import { NOT_SUPPORTED_MESSAGE } from "~/config/AppConfig"; +import { + LOGIN_REDIRECT_MESSAGE_KEY, + type LoginRedirectMessage, +} from "~/platform/auth/constants"; import { apiClient, type SelfManagedApiClient } from "../apiClient"; @@ -83,8 +87,20 @@ export async function logout(logoutParams: { export async function logoutAndRedirect(logoutParams: { apiClient: SelfManagedApiClient; + message?: LoginRedirectMessage; }) { logout(logoutParams); + if (logoutParams.message) { + try { + sessionStorage.setItem( + LOGIN_REDIRECT_MESSAGE_KEY, + JSON.stringify(logoutParams.message), + ); + } catch { + // Storage unavailable (private browsing, quota). Fall through to a + // silent redirect. + } + } window.location.href = LOGIN_PATH; } diff --git a/console/src/platform/UnauthenticatedRoutes.tsx b/console/src/platform/UnauthenticatedRoutes.tsx index 43beec0c9779e..f2215ff74e551 100644 --- a/console/src/platform/UnauthenticatedRoutes.tsx +++ b/console/src/platform/UnauthenticatedRoutes.tsx @@ -22,6 +22,7 @@ import { AuthenticatedRoutes } from "~/platform/AuthenticatedRoutes"; import { SentryRoutes } from "~/sentry"; import { Login } from "./auth/Login"; +import { OidcCallback } from "./auth/OidcCallback"; const OidcAuthGuard = ({ children }: React.PropsWithChildren) => { const auth = useAuth(); @@ -47,7 +48,7 @@ const SelfManagedRoutes = ({ {(appConfig.authMode === "Password" || appConfig.authMode === "Sasl" || isOidc) && } />} - {isOidc && } />} + {isOidc && } />} { + if (typeof value !== "object" || value === null) return false; + const v = value as { reason?: unknown; detail?: unknown }; + const validReason = + v.reason === "auth_rejected" || v.reason === "session_expired"; + const validDetail = v.detail === undefined || typeof v.detail === "string"; + return validReason && validDetail; +}; + +/** Reads and clears the one-shot message set by {@link logoutAndRedirect}. */ +const consumeLoginRedirectMessage = (): LoginRedirectMessage | null => { + let raw: string | null = null; + try { + raw = sessionStorage.getItem(LOGIN_REDIRECT_MESSAGE_KEY); + sessionStorage.removeItem(LOGIN_REDIRECT_MESSAGE_KEY); + } catch { + return null; + } + if (!raw) return null; + try { + const parsed: unknown = JSON.parse(raw); + return isLoginRedirectMessage(parsed) ? parsed : null; + } catch { + return null; + } +}; + type LoginFormState = { username: string; @@ -186,6 +222,15 @@ export const Login = () => { const isOidc = appConfig.mode === "self-managed" && appConfig.authMode === "Oidc"; + // Consume once on mount so a manual refresh of the login page clears it. + const [redirectMessage] = useState(consumeLoginRedirectMessage); + const reasonMessage = + redirectMessage?.reason === "auth_rejected" + ? buildAuthRejectedMessage(redirectMessage.detail ?? null) + : redirectMessage + ? LOGIN_REASON_MESSAGES[redirectMessage.reason] + : null; + return ( @@ -193,6 +238,16 @@ export const Login = () => { + {reasonMessage && ( + + )} {isOidc && } diff --git a/console/src/platform/auth/OidcCallback.tsx b/console/src/platform/auth/OidcCallback.tsx new file mode 100644 index 0000000000000..22457e9d07393 --- /dev/null +++ b/console/src/platform/auth/OidcCallback.tsx @@ -0,0 +1,53 @@ +// Copyright Materialize, Inc. and contributors. All rights reserved. +// +// Use of this software is governed by the Business Source License +// included in the LICENSE file. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0. + +import { Heading, HStack, Text, VStack } from "@chakra-ui/react"; +import React from "react"; +import { Link as RouterLink } from "react-router-dom"; + +import { LOGIN_PATH } from "~/api/materialize/auth"; +import Alert from "~/components/Alert"; +import LoadingScreen from "~/components/LoadingScreen"; +import { MaterializeLogo } from "~/components/MaterializeLogo"; +import { useAuth } from "~/external-library-wrappers/oidc"; +import { AuthContentContainer, AuthLayout } from "~/layouts/AuthLayout"; + +// Surfaces OIDC callback errors (access_denied, state mismatch, etc.) +export const OidcCallback = () => { + const auth = useAuth(); + + if (!auth.error) return ; + + return ( + + + + + + + + Sign-in failed + + We couldn't complete your sign-in with your identity + provider. If this keeps happening, confirm you've been + granted access to Materialize. + + + + + + + ); +}; diff --git a/console/src/platform/auth/constants.ts b/console/src/platform/auth/constants.ts new file mode 100644 index 0000000000000..8b025c1fa77fb --- /dev/null +++ b/console/src/platform/auth/constants.ts @@ -0,0 +1,34 @@ +// Copyright Materialize, Inc. and contributors. All rights reserved. +// +// Use of this software is governed by the Business Source License +// included in the LICENSE file. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0. + +// `sessionStorage` key used to pass a one-shot message across the redirect +// that the login page reads and immediately clears. +export const LOGIN_REDIRECT_MESSAGE_KEY = "mz.loginRedirectMessage"; + +export type LoginReason = "auth_rejected" | "session_expired"; + +export type LoginRedirectMessage = { + reason: LoginReason; + detail?: string; +}; + +const AUTH_REJECTED_SUFFIX = + " Please try again, or contact your administrator if this persists."; + +export const LOGIN_REASON_MESSAGES: Record = { + auth_rejected: `Sign-in was rejected.${AUTH_REJECTED_SUFFIX}`, + session_expired: "Your previous session ended. Please sign in again.", +}; + +/** Embeds the sanitized server-side detail (e.g. "invalid audience") into the + * rejected-login copy. */ +export const buildAuthRejectedMessage = (detail: string | null): string => { + if (!detail) return LOGIN_REASON_MESSAGES.auth_rejected; + return `Sign-in was rejected: ${detail}.${AUTH_REJECTED_SUFFIX}`; +}; diff --git a/console/src/utils/oidcAuth.ts b/console/src/utils/oidcAuth.ts new file mode 100644 index 0000000000000..1675321c18020 --- /dev/null +++ b/console/src/utils/oidcAuth.ts @@ -0,0 +1,35 @@ +// Copyright Materialize, Inc. and contributors. All rights reserved. +// +// Use of this software is governed by the Business Source License +// included in the LICENSE file. +// +// As of the Change Date specified in that file, in accordance with +// the Business Source License, use of this software will be governed +// by the Apache License, Version 2.0. + +import { type LoginReason } from "~/platform/auth/constants"; + +// Cap on the forwarded OIDC detail so a malformed 401 body can't bloat the URL. +const MAX_AUTH_DETAIL_LENGTH = 200; + +const hasBearer = (input: Parameters[0]): boolean => + input instanceof Request && + (input.headers.get("Authorization")?.startsWith("Bearer ") ?? false); + +/** Reads the sanitized `OidcError::Display` environmentd returns for OIDC 401s. */ +export const readAuthErrorDetail = async ( + response: Response, +): Promise => { + try { + const body = (await response.clone().text()).trim(); + if (!body || body === "unauthorized") return undefined; + return body.slice(0, MAX_AUTH_DETAIL_LENGTH); + } catch { + return undefined; + } +}; + +// Returns `auth_rejected` only when a Bearer was sent. +export const formatLoginErrorForOIDC = ( + input: Parameters[0], +): LoginReason | undefined => (hasBearer(input) ? "auth_rejected" : undefined); diff --git a/src/environmentd/src/http.rs b/src/environmentd/src/http.rs index 59c07e052750e..8ab5a1e6d5d3e 100644 --- a/src/environmentd/src/http.rs +++ b/src/environmentd/src/http.rs @@ -854,26 +854,33 @@ pub(crate) enum AuthError { FailedToUpdateSession, #[error("invalid credentials")] InvalidCredentials, + /// Payload is `OidcError`'s sanitized `Display` (no expected-values leaks). + #[error("{0}")] + OidcFailed(String), } impl IntoResponse for AuthError { fn into_response(self) -> Response { warn!("HTTP request failed authentication: {}", self); let mut headers = HeaderMap::new(); - match self { + // We omit most detail from the error message we send to the client, to + // avoid giving attackers unnecessary information. `OidcFailed` is the + // exception — its payload is a sanitized `OidcError::Display` that the + // console embeds in the login-page error. + let body = match &self { AuthError::MissingHttpAuthentication { include_www_authenticate_header, - } if include_www_authenticate_header => { + } if *include_www_authenticate_header => { headers.insert( http::header::WWW_AUTHENTICATE, HeaderValue::from_static("Basic realm=Materialize"), ); + "unauthorized".to_string() } - _ => {} + AuthError::OidcFailed(message) => message.clone(), + _ => "unauthorized".to_string(), }; - // We omit most detail from the error message we send to the client, to - // avoid giving attackers unnecessary information. - (StatusCode::UNAUTHORIZED, headers, "unauthorized").into_response() + (StatusCode::UNAUTHORIZED, headers, body).into_response() } } @@ -1241,11 +1248,10 @@ async fn auth( } Authenticator::Oidc(oidc) => match creds { Some(Credentials::Token { token }) => { - // Validate JWT token let (mut claims, authenticated) = oidc .authenticate(&token, None) .await - .map_err(|_| AuthError::InvalidCredentials)?; + .map_err(|e| AuthError::OidcFailed(e.to_string()))?; let name = std::mem::take(&mut claims.user); (name, None, authenticated) }