Skip to content

Commit d327cb6

Browse files
authored
console: fix password auth when oidc token expires (#37238)
OIDC auth middleware attaches the cached id_token as a Bearer header on every API request. Envd reuses the password session cookie only when the request carries no credentials. When the OIDC token expired, next request still contained the stale expired token and was rejected with the "authentication credentials have expired" error. Fixed the MzOidcUserManager to check the id_token's expiry so it doesn't break when envd tries validating it [Fixes CNS-91](<https://linear.app/materializeinc/issue/CNS-91/fix-expired-oidc-logins-causing-password-auth-to-break>) ## To repro: * Set the ID token to 60s in the IDP * Login using SSO * When SSO expires, user sees this <img src="https://uploads.linear.app/974a4381-d068-46e0-9fcd-1a1c00168131/1232c1cf-8281-43f6-84f4-1fe7bea8b52b/9369ba86-a002-4fc8-8733-ad0ee5a403af?signature=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJwYXRoIjoiLzk3NGE0MzgxLWQwNjgtNDZlMC05ZmNkLTFhMWMwMDE2ODEzMS8xMjMyYzFjZi04MjgxLTQzZjYtODRmNC0xZmU3YmVhOGI1MmIvOTM2OWJhODYtYTAwMi00ZmM4LTg3MzMtYWQwZWU1YTQwM2FmIiwiaWF0IjoxNzgyMzE5NTk2LCJleHAiOjE4MTM4OTAxNTZ9.VJokSTNuwum5bN2wCg1Ac4tNkdeQc62AgMp3aMw5Qcs " alt="image" width="763" data-linear-height="858" /> * Try logging in using password auth, can't log back in using password auth ### Verification https://github.com/user-attachments/assets/cabab8f3-8243-4163-a521-e46caae24a29
1 parent 50f1e5c commit d327cb6

4 files changed

Lines changed: 68 additions & 12 deletions

File tree

console/src/api/materialize/auth.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,23 @@ export async function loginOrThrow(request: { payload: LoginRequest }) {
6161
return responseText;
6262
}
6363

64+
export async function hasActiveSession(): Promise<boolean> {
65+
const { authApiBasePath } = getApiClient();
66+
67+
try {
68+
const response = await fetch(`${authApiBasePath}/api/sql`, {
69+
method: "POST",
70+
headers: {
71+
"Content-Type": "application/json",
72+
},
73+
body: JSON.stringify({ queries: [{ query: "SELECT 1", params: [] }] }),
74+
});
75+
return response.ok;
76+
} catch {
77+
return false;
78+
}
79+
}
80+
6481
export async function logout(logoutParams: {
6582
apiClient: SelfManagedApiClient;
6683
}) {

console/src/external-library-wrappers/oidc.ts

Lines changed: 21 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ export {
2121
} from "react-oidc-context";
2222

2323
import { useQuery } from "@tanstack/react-query";
24-
import { UserManager, WebStorageStateStore } from "oidc-client-ts";
24+
import { User, UserManager, WebStorageStateStore } from "oidc-client-ts";
2525

2626
import { apiClient } from "~/api/apiClient";
2727
import { useAppConfig } from "~/config/useAppConfig";
@@ -76,7 +76,7 @@ async function fetchOidcConfig(): Promise<OidcConfig> {
7676
*/
7777
export class MzOidcUserManager {
7878
#userManager: UserManager;
79-
#cachedIdToken: string | undefined;
79+
#cachedToken: { value: string; expiresAtMs: number } | undefined;
8080

8181
constructor(config: OidcConfig) {
8282
this.#userManager = new UserManager({
@@ -91,26 +91,40 @@ export class MzOidcUserManager {
9191
});
9292

9393
this.#userManager.events.addUserLoaded((user) => {
94-
this.#cachedIdToken = user.id_token;
94+
this.#setCachedToken(user);
9595
});
9696

9797
this.#userManager.events.addUserUnloaded(() => {
98-
this.#cachedIdToken = undefined;
98+
this.#cachedToken = undefined;
9999
});
100100

101101
// Eagerly populate the cached token from storage so that API requests
102102
// made immediately after page load can include the Authorization header.
103103
// The userLoaded event only fires on sign-in/silent-renew, not on
104104
// loading an existing session from storage.
105105
this.#userManager.getUser().then((user) => {
106-
if (user && !this.#cachedIdToken) {
107-
this.#cachedIdToken = user.id_token;
106+
if (user && !this.#cachedToken) {
107+
this.#setCachedToken(user);
108108
}
109109
});
110110
}
111111

112+
// Don't send an expired id_token: the OIDC Bearer header makes environmentd
113+
// validate it instead of the password session cookie, breaking password
114+
// fallback.
112115
getIdToken(): string | undefined {
113-
return this.#cachedIdToken;
116+
if (!this.#cachedToken || this.#cachedToken.expiresAtMs <= Date.now()) {
117+
return undefined;
118+
}
119+
return this.#cachedToken.value;
120+
}
121+
122+
// Extract the id_token and its `exp` (the claim environmentd validates) up
123+
// front, so getIdToken() can drop an expired token without decoding the JWT.
124+
#setCachedToken(user: User) {
125+
this.#cachedToken = user.id_token
126+
? { value: user.id_token, expiresAtMs: user.profile.exp * 1000 }
127+
: undefined;
114128
}
115129

116130
getUserManager(): UserManager {

console/src/hooks/useSelfManagedProfile.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -25,13 +25,17 @@ export const useSelfManagedProfile = (
2525
auth: AuthContextProps | undefined,
2626
): SelfManagedProfile => {
2727
const profile = auth?.user?.profile;
28+
const isProfileValid =
29+
typeof profile?.exp === "number" && profile.exp * 1000 > Date.now();
30+
const oidcProfile = isProfileValid ? profile : undefined;
2831
const { results: sqlRole, isLoading } = useCurrentUser();
2932

30-
const oidcName = typeof profile?.name === "string" ? profile.name : undefined;
33+
const oidcName =
34+
typeof oidcProfile?.name === "string" ? oidcProfile.name : undefined;
3135
const oidcEmail =
32-
typeof profile?.email === "string" ? profile.email : undefined;
36+
typeof oidcProfile?.email === "string" ? oidcProfile.email : undefined;
3337
const oidcPicture =
34-
typeof profile?.picture === "string" ? profile.picture : undefined;
38+
typeof oidcProfile?.picture === "string" ? oidcProfile.picture : undefined;
3539

3640
return {
3741
name: oidcName ?? sqlRole,

console/src/platform/UnauthenticatedRoutes.tsx

Lines changed: 23 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,10 +7,11 @@
77
// the Business Source License, use of this software will be governed
88
// by the Apache License, Version 2.0.
99

10+
import { useQuery } from "@tanstack/react-query";
1011
import React from "react";
1112
import { Navigate, Route } from "react-router-dom";
1213

13-
import { LOGIN_PATH } from "~/api/materialize/auth";
14+
import { hasActiveSession, LOGIN_PATH } from "~/api/materialize/auth";
1415
import { LaunchDarklyProvider } from "~/components/LaunchDarkly";
1516
import LoadingScreen from "~/components/LoadingScreen";
1617
import { type SelfManagedAppConfig } from "~/config/AppConfig";
@@ -27,6 +28,26 @@ import { SentryRoutes } from "~/sentry";
2728
import { Login } from "./auth/Login";
2829
import { OidcCallback } from "./auth/OidcCallback";
2930

31+
// Redirect already-signed-in users off the login page. The password session
32+
// cookie is httpOnly, so probe the server; a live OIDC token skips the probe.
33+
const LoginRoute = () => {
34+
const { data: oidcManager } = useOidcManagerQuery();
35+
const hasOidcToken = Boolean(oidcManager?.getIdToken());
36+
37+
const { data: hasCookieSession } = useQuery({
38+
queryKey: ["hasActiveSession"],
39+
queryFn: hasActiveSession,
40+
enabled: !hasOidcToken,
41+
staleTime: Infinity,
42+
retry: false,
43+
});
44+
45+
if (hasOidcToken || hasCookieSession) {
46+
return <Navigate to="/" replace />;
47+
}
48+
return <Login />;
49+
};
50+
3051
const OidcAuthGuard = ({ children }: React.PropsWithChildren) => {
3152
const { isLoading, data: auth } = useOidcManagerQuery();
3253

@@ -55,7 +76,7 @@ const SelfManagedRoutes = ({
5576
<SentryRoutes>
5677
{(appConfig.authMode === "Password" ||
5778
appConfig.authMode === "Sasl" ||
58-
isOidc) && <Route path={LOGIN_PATH} element={<Login />} />}
79+
isOidc) && <Route path={LOGIN_PATH} element={<LoginRoute />} />}
5980
{isOidc && <Route path="/auth/callback" element={<OidcCallback />} />}
6081
<Route
6182
path="*"

0 commit comments

Comments
 (0)