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
13 changes: 11 additions & 2 deletions console/src/api/apiClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -177,9 +178,17 @@ export class SelfManagedApiClient

#mzApiWithAuthRedirect = async (...req: Parameters<Fetch>) => {
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;
};
Expand Down
16 changes: 16 additions & 0 deletions console/src/api/materialize/auth.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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;
}

Expand Down
3 changes: 2 additions & 1 deletion console/src/platform/UnauthenticatedRoutes.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand All @@ -47,7 +48,7 @@ const SelfManagedRoutes = ({
{(appConfig.authMode === "Password" ||
appConfig.authMode === "Sasl" ||
isOidc) && <Route path={LOGIN_PATH} element={<Login />} />}
{isOidc && <Route path="/auth/callback" element={<LoadingScreen />} />}
{isOidc && <Route path="/auth/callback" element={<OidcCallback />} />}
<Route
path="*"
element={
Expand Down
55 changes: 55 additions & 0 deletions console/src/platform/auth/Login.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,42 @@ import EyeClosedIcon from "~/svg/EyeClosedIcon";
import EyeOpenIcon from "~/svg/EyeOpenIcon";
import { MaterializeTheme } from "~/theme";

import {
buildAuthRejectedMessage,
LOGIN_REASON_MESSAGES,
LOGIN_REDIRECT_MESSAGE_KEY,
type LoginRedirectMessage,
} from "./constants";

const isLoginRedirectMessage = (
value: unknown,
): value is LoginRedirectMessage => {
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;

Expand Down Expand Up @@ -186,13 +222,32 @@ 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 (
<AuthLayout>
<AuthContentContainer>
<VStack alignItems="stretch" width="100%" mx="12">
<HStack my={{ base: "8", lg: "0" }} paddingBottom="8">
<MaterializeLogo height="12" />
</HStack>
{reasonMessage && (
<Alert
variant={
redirectMessage?.reason === "auth_rejected" ? "error" : "info"
}
minWidth="100%"
message={reasonMessage}
mb="4"
/>
)}
<PasswordLoginForm />
{isOidc && <SsoLoginLink />}
</VStack>
Expand Down
53 changes: 53 additions & 0 deletions console/src/platform/auth/OidcCallback.tsx
Original file line number Diff line number Diff line change
@@ -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 <LoadingScreen />;
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think we should unify the error behavior with errors from the Materialize side and just redirect to the login page with the error message.


return (
<AuthLayout>
<AuthContentContainer>
<VStack alignItems="stretch" width="100%" mx="12">
<HStack my={{ base: "8", lg: "0" }} paddingBottom="8">
<MaterializeLogo height="12" />
</HStack>
<VStack alignItems="stretch" spacing="2" paddingBottom="6">
<Heading size="md">Sign-in failed</Heading>
<Text fontSize="sm">
We couldn&apos;t complete your sign-in with your identity
provider. If this keeps happening, confirm you&apos;ve been
granted access to Materialize.
</Text>
</VStack>
<Alert
variant="error"
message={auth.error.message || "Sign-in failed. Please try again."}
showButton
buttonText="Back to sign in"
buttonProps={{ as: RouterLink, to: LOGIN_PATH, replace: true }}
/>
</VStack>
</AuthContentContainer>
</AuthLayout>
);
};
34 changes: 34 additions & 0 deletions console/src/platform/auth/constants.ts
Original file line number Diff line number Diff line change
@@ -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<LoginReason, string> = {
auth_rejected: `Sign-in was rejected.${AUTH_REJECTED_SUFFIX}`,
session_expired: "Your previous session ended. Please sign in again.",
Comment on lines +24 to +26
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

We could just not print an error for session expiration because we don't know definitively that a 401 with no detail is the session expiring.

};

/** 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}`;
};
35 changes: 35 additions & 0 deletions console/src/utils/oidcAuth.ts
Original file line number Diff line number Diff line change
@@ -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<typeof fetch>[0]): boolean =>
input instanceof Request &&
(input.headers.get("Authorization")?.startsWith("Bearer ") ?? false);

/** Reads the sanitized `OidcError::Display` environmentd returns for OIDC 401s. */
Comment on lines +15 to +19
Copy link
Copy Markdown
Contributor

@SangJunBak SangJunBak Apr 23, 2026

Choose a reason for hiding this comment

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

This feels like a hacky way to differentiate login errors from session expiration. Rather than use local storage, can we encode the error message into the URL (like a search param) then on the login page, just show the error there?

export const readAuthErrorDetail = async (
response: Response,
): Promise<string | undefined> => {
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<typeof fetch>[0],
): LoginReason | undefined => (hasBearer(input) ? "auth_rejected" : undefined);
22 changes: 14 additions & 8 deletions src/environmentd/src/http.rs
Original file line number Diff line number Diff line change
Expand Up @@ -854,26 +854,33 @@ pub(crate) enum AuthError {
FailedToUpdateSession,
#[error("invalid credentials")]
InvalidCredentials,
/// Payload is `OidcError`'s sanitized `Display` (no expected-values leaks).
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I think similar to the pgwire tests in src/environmentd/tests/auth.rs, we should have tests asserting the details for the http errors.

#[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()
}
}

Expand Down Expand Up @@ -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)
}
Expand Down
Loading