Skip to content

Commit ecb9e14

Browse files
committed
console,environmentd: surface OIDC sign-in failures on the login page
* Add `OidcCallback` route component so IdP callback errors render instead of an indefinite loading spinner. * Add `AuthError::OidcFailed` carrying the sanitized `OidcError::Display` in the 401 body, so environmentd token rejections can be surfaced to the user without leaking internal config. * Wire the 401 interceptor to post a one-shot `{reason, detail}` message to `sessionStorage`; the login page renders it as an `Alert`
1 parent d7ba94d commit ecb9e14

8 files changed

Lines changed: 220 additions & 11 deletions

File tree

console/src/api/apiClient.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ import {
2020
} from "~/config/AppConfig";
2121
import { ContextHolder } from "~/external-library-wrappers/frontegg";
2222
import { MzOidcUserManager } from "~/external-library-wrappers/oidc";
23+
import { formatLoginErrorForOIDC, readAuthErrorDetail } from "~/utils/oidcAuth";
2324

2425
import { logoutAndRedirect } from "./materialize/auth";
2526
import {
@@ -177,9 +178,17 @@ export class SelfManagedApiClient
177178

178179
#mzApiWithAuthRedirect = async (...req: Parameters<Fetch>) => {
179180
const response = await globalFetch(...req);
180-
// If the user is not authenticated, we redirect to the login page.
181181
if (response.status === 401) {
182-
await logoutAndRedirect({ apiClient: this });
182+
const reason =
183+
this.authMode === "Oidc"
184+
? formatLoginErrorForOIDC(req[0])
185+
: "session_expired";
186+
const detail =
187+
reason === "auth_rejected"
188+
? await readAuthErrorDetail(response)
189+
: undefined;
190+
const message = reason ? { reason, detail } : undefined;
191+
await logoutAndRedirect({ apiClient: this, message });
183192
}
184193
return response;
185194
};

console/src/api/materialize/auth.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,10 @@
88
// by the Apache License, Version 2.0.
99

1010
import { NOT_SUPPORTED_MESSAGE } from "~/config/AppConfig";
11+
import {
12+
LOGIN_REDIRECT_MESSAGE_KEY,
13+
type LoginRedirectMessage,
14+
} from "~/platform/auth/constants";
1115

1216
import { apiClient, type SelfManagedApiClient } from "../apiClient";
1317

@@ -83,8 +87,20 @@ export async function logout(logoutParams: {
8387

8488
export async function logoutAndRedirect(logoutParams: {
8589
apiClient: SelfManagedApiClient;
90+
message?: LoginRedirectMessage;
8691
}) {
8792
logout(logoutParams);
93+
if (logoutParams.message) {
94+
try {
95+
sessionStorage.setItem(
96+
LOGIN_REDIRECT_MESSAGE_KEY,
97+
JSON.stringify(logoutParams.message),
98+
);
99+
} catch {
100+
// Storage unavailable (private browsing, quota). Fall through to a
101+
// silent redirect.
102+
}
103+
}
88104
window.location.href = LOGIN_PATH;
89105
}
90106

console/src/platform/UnauthenticatedRoutes.tsx

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ import { AuthenticatedRoutes } from "~/platform/AuthenticatedRoutes";
2222
import { SentryRoutes } from "~/sentry";
2323

2424
import { Login } from "./auth/Login";
25+
import { OidcCallback } from "./auth/OidcCallback";
2526

2627
const OidcAuthGuard = ({ children }: React.PropsWithChildren) => {
2728
const auth = useAuth();
@@ -47,7 +48,7 @@ const SelfManagedRoutes = ({
4748
{(appConfig.authMode === "Password" ||
4849
appConfig.authMode === "Sasl" ||
4950
isOidc) && <Route path={LOGIN_PATH} element={<Login />} />}
50-
{isOidc && <Route path="/auth/callback" element={<LoadingScreen />} />}
51+
{isOidc && <Route path="/auth/callback" element={<OidcCallback />} />}
5152
<Route
5253
path="*"
5354
element={

console/src/platform/auth/Login.tsx

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,42 @@ import EyeClosedIcon from "~/svg/EyeClosedIcon";
3636
import EyeOpenIcon from "~/svg/EyeOpenIcon";
3737
import { MaterializeTheme } from "~/theme";
3838

39+
import {
40+
buildAuthRejectedMessage,
41+
LOGIN_REASON_MESSAGES,
42+
LOGIN_REDIRECT_MESSAGE_KEY,
43+
type LoginRedirectMessage,
44+
} from "./constants";
45+
46+
const isLoginRedirectMessage = (
47+
value: unknown,
48+
): value is LoginRedirectMessage => {
49+
if (typeof value !== "object" || value === null) return false;
50+
const v = value as { reason?: unknown; detail?: unknown };
51+
const validReason =
52+
v.reason === "auth_rejected" || v.reason === "session_expired";
53+
const validDetail = v.detail === undefined || typeof v.detail === "string";
54+
return validReason && validDetail;
55+
};
56+
57+
/** Reads and clears the one-shot message set by {@link logoutAndRedirect}. */
58+
const consumeLoginRedirectMessage = (): LoginRedirectMessage | null => {
59+
let raw: string | null = null;
60+
try {
61+
raw = sessionStorage.getItem(LOGIN_REDIRECT_MESSAGE_KEY);
62+
sessionStorage.removeItem(LOGIN_REDIRECT_MESSAGE_KEY);
63+
} catch {
64+
return null;
65+
}
66+
if (!raw) return null;
67+
try {
68+
const parsed: unknown = JSON.parse(raw);
69+
return isLoginRedirectMessage(parsed) ? parsed : null;
70+
} catch {
71+
return null;
72+
}
73+
};
74+
3975
type LoginFormState = {
4076
username: string;
4177

@@ -186,13 +222,32 @@ export const Login = () => {
186222
const isOidc =
187223
appConfig.mode === "self-managed" && appConfig.authMode === "Oidc";
188224

225+
// Consume once on mount so a manual refresh of the login page clears it.
226+
const [redirectMessage] = useState(consumeLoginRedirectMessage);
227+
const reasonMessage =
228+
redirectMessage?.reason === "auth_rejected"
229+
? buildAuthRejectedMessage(redirectMessage.detail ?? null)
230+
: redirectMessage
231+
? LOGIN_REASON_MESSAGES[redirectMessage.reason]
232+
: null;
233+
189234
return (
190235
<AuthLayout>
191236
<AuthContentContainer>
192237
<VStack alignItems="stretch" width="100%" mx="12">
193238
<HStack my={{ base: "8", lg: "0" }} paddingBottom="8">
194239
<MaterializeLogo height="12" />
195240
</HStack>
241+
{reasonMessage && (
242+
<Alert
243+
variant={
244+
redirectMessage?.reason === "auth_rejected" ? "error" : "info"
245+
}
246+
minWidth="100%"
247+
message={reasonMessage}
248+
mb="4"
249+
/>
250+
)}
196251
<PasswordLoginForm />
197252
{isOidc && <SsoLoginLink />}
198253
</VStack>
Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
// Copyright Materialize, Inc. and contributors. All rights reserved.
2+
//
3+
// Use of this software is governed by the Business Source License
4+
// included in the LICENSE file.
5+
//
6+
// As of the Change Date specified in that file, in accordance with
7+
// the Business Source License, use of this software will be governed
8+
// by the Apache License, Version 2.0.
9+
10+
import { Heading, HStack, Text, VStack } from "@chakra-ui/react";
11+
import React from "react";
12+
import { Link as RouterLink } from "react-router-dom";
13+
14+
import { LOGIN_PATH } from "~/api/materialize/auth";
15+
import Alert from "~/components/Alert";
16+
import LoadingScreen from "~/components/LoadingScreen";
17+
import { MaterializeLogo } from "~/components/MaterializeLogo";
18+
import { useAuth } from "~/external-library-wrappers/oidc";
19+
import { AuthContentContainer, AuthLayout } from "~/layouts/AuthLayout";
20+
21+
// Surfaces OIDC callback errors (access_denied, state mismatch, etc.)
22+
export const OidcCallback = () => {
23+
const auth = useAuth();
24+
25+
if (!auth.error) return <LoadingScreen />;
26+
27+
return (
28+
<AuthLayout>
29+
<AuthContentContainer>
30+
<VStack alignItems="stretch" width="100%" mx="12">
31+
<HStack my={{ base: "8", lg: "0" }} paddingBottom="8">
32+
<MaterializeLogo height="12" />
33+
</HStack>
34+
<VStack alignItems="stretch" spacing="2" paddingBottom="6">
35+
<Heading size="md">Sign-in failed</Heading>
36+
<Text fontSize="sm">
37+
We couldn&apos;t complete your sign-in with your identity
38+
provider. If this keeps happening, confirm you&apos;ve been
39+
granted access to Materialize.
40+
</Text>
41+
</VStack>
42+
<Alert
43+
variant="error"
44+
message={auth.error.message || "Sign-in failed. Please try again."}
45+
showButton
46+
buttonText="Back to sign in"
47+
buttonProps={{ as: RouterLink, to: LOGIN_PATH, replace: true }}
48+
/>
49+
</VStack>
50+
</AuthContentContainer>
51+
</AuthLayout>
52+
);
53+
};
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
// Copyright Materialize, Inc. and contributors. All rights reserved.
2+
//
3+
// Use of this software is governed by the Business Source License
4+
// included in the LICENSE file.
5+
//
6+
// As of the Change Date specified in that file, in accordance with
7+
// the Business Source License, use of this software will be governed
8+
// by the Apache License, Version 2.0.
9+
10+
// `sessionStorage` key used to pass a one-shot message across the redirect
11+
// that the login page reads and immediately clears.
12+
export const LOGIN_REDIRECT_MESSAGE_KEY = "mz.loginRedirectMessage";
13+
14+
export type LoginReason = "auth_rejected" | "session_expired";
15+
16+
export type LoginRedirectMessage = {
17+
reason: LoginReason;
18+
detail?: string;
19+
};
20+
21+
const AUTH_REJECTED_SUFFIX =
22+
" Please try again, or contact your administrator if this persists.";
23+
24+
export const LOGIN_REASON_MESSAGES: Record<LoginReason, string> = {
25+
auth_rejected: `Sign-in was rejected.${AUTH_REJECTED_SUFFIX}`,
26+
session_expired: "Your previous session ended. Please sign in again.",
27+
};
28+
29+
/** Embeds the sanitized server-side detail (e.g. "invalid audience") into the
30+
* rejected-login copy. */
31+
export const buildAuthRejectedMessage = (detail: string | null): string => {
32+
if (!detail) return LOGIN_REASON_MESSAGES.auth_rejected;
33+
return `Sign-in was rejected: ${detail}.${AUTH_REJECTED_SUFFIX}`;
34+
};

console/src/utils/oidcAuth.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
// Copyright Materialize, Inc. and contributors. All rights reserved.
2+
//
3+
// Use of this software is governed by the Business Source License
4+
// included in the LICENSE file.
5+
//
6+
// As of the Change Date specified in that file, in accordance with
7+
// the Business Source License, use of this software will be governed
8+
// by the Apache License, Version 2.0.
9+
10+
import { type LoginReason } from "~/platform/auth/constants";
11+
12+
// Cap on the forwarded OIDC detail so a malformed 401 body can't bloat the URL.
13+
const MAX_AUTH_DETAIL_LENGTH = 200;
14+
15+
const hasBearer = (input: Parameters<typeof fetch>[0]): boolean =>
16+
input instanceof Request &&
17+
(input.headers.get("Authorization")?.startsWith("Bearer ") ?? false);
18+
19+
/** Reads the sanitized `OidcError::Display` environmentd returns for OIDC 401s. */
20+
export const readAuthErrorDetail = async (
21+
response: Response,
22+
): Promise<string | undefined> => {
23+
try {
24+
const body = (await response.clone().text()).trim();
25+
if (!body || body === "unauthorized") return undefined;
26+
return body.slice(0, MAX_AUTH_DETAIL_LENGTH);
27+
} catch {
28+
return undefined;
29+
}
30+
};
31+
32+
// Returns `auth_rejected` only when a Bearer was sent.
33+
export const formatLoginErrorForOIDC = (
34+
input: Parameters<typeof fetch>[0],
35+
): LoginReason | undefined => (hasBearer(input) ? "auth_rejected" : undefined);

src/environmentd/src/http.rs

Lines changed: 14 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -854,26 +854,33 @@ pub(crate) enum AuthError {
854854
FailedToUpdateSession,
855855
#[error("invalid credentials")]
856856
InvalidCredentials,
857+
/// Payload is `OidcError`'s sanitized `Display` (no expected-values leaks).
858+
#[error("{0}")]
859+
OidcFailed(String),
857860
}
858861

859862
impl IntoResponse for AuthError {
860863
fn into_response(self) -> Response {
861864
warn!("HTTP request failed authentication: {}", self);
862865
let mut headers = HeaderMap::new();
863-
match self {
866+
// We omit most detail from the error message we send to the client, to
867+
// avoid giving attackers unnecessary information. `OidcFailed` is the
868+
// exception — its payload is a sanitized `OidcError::Display` that the
869+
// console embeds in the login-page error.
870+
let body = match &self {
864871
AuthError::MissingHttpAuthentication {
865872
include_www_authenticate_header,
866-
} if include_www_authenticate_header => {
873+
} if *include_www_authenticate_header => {
867874
headers.insert(
868875
http::header::WWW_AUTHENTICATE,
869876
HeaderValue::from_static("Basic realm=Materialize"),
870877
);
878+
"unauthorized".to_string()
871879
}
872-
_ => {}
880+
AuthError::OidcFailed(message) => message.clone(),
881+
_ => "unauthorized".to_string(),
873882
};
874-
// We omit most detail from the error message we send to the client, to
875-
// avoid giving attackers unnecessary information.
876-
(StatusCode::UNAUTHORIZED, headers, "unauthorized").into_response()
883+
(StatusCode::UNAUTHORIZED, headers, body).into_response()
877884
}
878885
}
879886

@@ -1241,11 +1248,10 @@ async fn auth(
12411248
}
12421249
Authenticator::Oidc(oidc) => match creds {
12431250
Some(Credentials::Token { token }) => {
1244-
// Validate JWT token
12451251
let (mut claims, authenticated) = oidc
12461252
.authenticate(&token, None)
12471253
.await
1248-
.map_err(|_| AuthError::InvalidCredentials)?;
1254+
.map_err(|e| AuthError::OidcFailed(e.to_string()))?;
12491255
let name = std::mem::take(&mut claims.user);
12501256
(name, None, authenticated)
12511257
}

0 commit comments

Comments
 (0)