Skip to content

Commit 545e114

Browse files
authored
console: OIDC login in console (#35440)
Remove these sections if your commit already has a good description! ### Motivation This PR adds OIDC authentication for self managed console. [Linear issue: CNS-25](7127d37#diff-af9c8f7e6f839e4e44fed50cfbe560c70bd845b062b946eaec012d825721f3c6) ### Description For the console to handle oidc authentication, here are the changes in this PR: - Added `oidc-client-ts` and `react-oidc-context` libraries to the console - Created an OIDC library wrapper for oidc libary to create a shared `User Manager` that caches ID token and exports the id token to be consumed by api client - Tests that mock the oidc token and functions - Added "Oidc" to AppConfig so that oidc auth mode can be parsed from app-config.json - Created the `OidcProviderWrapper.tsx` to initialize OIDC UserManager and wrap the children in AuthProvider when authmode === oidc - In apiClient.ts, when `authMode === oidc`, oidc middleware will add a `Authorization: Bearer <idToken>` if token exists. If no token, no header is sent -- session cookie is used implicitly. If a session expires, 401 redirect would handle expired sessions - In the websocket, `getWsAuthConfig` returns the ID token if available, `null` otherwise - Allow "Oidc" in auth.ts for password auth to work alongside OIDC - In the Login Box, the password auth option would also render along with "Use Single Sign on" link **Auth Flow:** ``` User clicks "Use single sign-on" │ ▼ ┌──────────────────────────────────┐ │ Login.tsx: SsoLoginLink │ │ auth.signinRedirect() │ └──────────────┬───────────────────┘ │ │ oidc-client-ts: │ 1. Generate code_verifier + code_challenge │ 2. Store in sessionStorage │ 3. Redirect browser │ ▼ ┌──────────────────────────────────┐ │ Auth0 /authorize │ │ ?response_type=code │ │ &client_id=jM1iDueUB3ucXyUgPbsZ│ │ &redirect_uri=.../auth/callback │ │ &scope=openid profile email │ │ &code_challenge=<hash> │ │ &code_challenge_method=S256 │ │ &state=<random> │ └──────────────┬───────────────────┘ │ ▼ ┌──────────────────────────────────┐ │ Auth0 login page │ │ User enters credentials / MFA │ └──────────────┬───────────────────┘ │ │ Auth0 redirects: │ /auth/callback?code=<authz_code>&state=<random> │ ▼ ┌──────────────────────────────────┐ │ UnauthenticatedRoutes.tsx │ │ OidcAuthGuard │ │ │ │ hasAuthParams() → true │ │ (code + state in URL) │ │ → render <LoadingScreen /> │ └──────────────┬───────────────────┘ │ │ AuthProvider (react-oidc-context) │ detects callback params, calls │ userManager.signinRedirectCallback() │ ▼ ┌──────────────────────────────────┐ │ oidc-client-ts token exchange │ │ │ │ POST Auth0 /oauth/token │ │ { │ │ grant_type: authorization_code│ │ code: <authz_code> │ │ code_verifier: <from storage> │ │ redirect_uri: .../auth/callback│ │ client_id: jM1iDueUB3ucXyUgPb│ │ } │ │ │ │ Auth0 responds: │ │ { id_token, access_token } │ └──────────────┬───────────────────┘ │ │ oidc-client-ts stores user │ in sessionStorage, fires │ userLoaded event │ ▼ ┌──────────────────────────────────┐ │ oidc.ts │ │ userLoaded callback: │ │ cachedIdToken = user.id_token │ └──────────────┬───────────────────┘ │ │ auth.isLoading → false │ auth.isAuthenticated → true │ OidcAuthGuard renders children │ ▼ ┌──────────────────────────────────┐ │ Authenticated Routes │ │ │ │ Every API call: │ │ apiClient.ts oidcAuthMiddleware │ │ → getOidcIdToken() returns token│ │ → Authorization: Bearer <token> │ └──────────────────────────────────┘ ``` ### Reviewing Tips - Would recommend reviewing it by commit. First commit sets up the oidc setup in the app. Second commit focuses on making it work with password auth ### Verification https://github.com/user-attachments/assets/49ca70b5-3e0d-48d6-88db-ca4ab20eb263
1 parent b09c311 commit 545e114

File tree

15 files changed

+583
-107
lines changed

15 files changed

+583
-107
lines changed

console/package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@
9090
"lodash.debounce": "^4.0.8",
9191
"markdown-table": "^3.0.4",
9292
"mitt": "^3.0.1",
93+
"oidc-client-ts": "^3.4.1",
9394
"openapi-fetch": "^0.17.0",
9495
"papaparse": "^5.4.1",
9596
"pg-error-enum": "^0.7.3",
@@ -98,6 +99,7 @@
9899
"react-dom": "^18.3.1",
99100
"react-hook-form": "^7.53.1",
100101
"react-intersection-observer": "^9.13.1",
102+
"react-oidc-context": "^3.3.0",
101103
"react-router-dom": "^6.27.0",
102104
"react-select": "^5.8.3",
103105
"react-virtualized-auto-sizer": "^1.0.24",

console/src/api/apiClient.test.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -296,3 +296,64 @@ describe("SelfManagedApiClient", () => {
296296
server.resetHandlers();
297297
});
298298
});
299+
300+
const MOCK_OIDC_CONFIG = {
301+
...MOCK_SELF_MANAGED_CONFIG,
302+
authMode: "Oidc" as const,
303+
} as SelfManagedAppConfig;
304+
305+
describe("SelfManagedApiClient (Oidc)", () => {
306+
it("should send Bearer header and return token ws config when OIDC token exists", async () => {
307+
const MOCK_TOKEN = "test-oidc-id-token";
308+
const client = new SelfManagedApiClient({
309+
appConfig: MOCK_OIDC_CONFIG,
310+
});
311+
await client.oidcManagerInitializationPromise;
312+
client.oidcManager = { getIdToken: () => MOCK_TOKEN } as any;
313+
314+
const TEST_URL = "https://oidc.example.com";
315+
let headers: Headers | null = null;
316+
server.use(
317+
http.get(TEST_URL, ({ request }) => {
318+
headers = request.headers;
319+
return HttpResponse.json({});
320+
}),
321+
);
322+
323+
await client.mzApiFetch(TEST_URL);
324+
expect(headers!.get("Authorization")).toBe(`Bearer ${MOCK_TOKEN}`);
325+
expect(client.getWsAuthConfig()).toEqual({ token: MOCK_TOKEN });
326+
server.resetHandlers();
327+
});
328+
329+
it("should skip Bearer header, return null ws config, and redirect on 401 when no OIDC token", async () => {
330+
const client = new SelfManagedApiClient({
331+
appConfig: MOCK_OIDC_CONFIG,
332+
});
333+
client.oidcManager = { getIdToken: () => undefined } as any;
334+
335+
expect(client.getWsAuthConfig()).toBeNull();
336+
337+
const TEST_URL = "https://oidc-no-token.example.com";
338+
let headers: Headers | null = null;
339+
const logoutSpy = vi.fn();
340+
server.use(
341+
http.get(TEST_URL, ({ request }) => {
342+
headers = request.headers;
343+
return new HttpResponse(null, { status: 401 });
344+
}),
345+
http.post(`${client.authApiBasePath}/api/logout`, () => {
346+
logoutSpy();
347+
return new HttpResponse(null, { status: 200 });
348+
}),
349+
);
350+
351+
await client.mzApiFetch(TEST_URL);
352+
expect(headers!.get("Authorization")).toBeNull();
353+
await vi.waitFor(() => {
354+
expect(logoutSpy).toHaveBeenCalled();
355+
});
356+
357+
server.resetHandlers();
358+
});
359+
});

console/src/api/apiClient.ts

Lines changed: 49 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import {
1919
SelfManagedAuthMode,
2020
} from "~/config/AppConfig";
2121
import { ContextHolder } from "~/external-library-wrappers/frontegg";
22+
import { MzOidcUserManager } from "~/external-library-wrappers/oidc";
2223

2324
import { logoutAndRedirect } from "./materialize/auth";
2425
import {
@@ -162,6 +163,10 @@ export class SelfManagedApiClient
162163
implements IApiClientBase, ISelfManagedApiClient
163164
{
164165
#appConfig: Readonly<SelfManagedAppConfig>;
166+
/** Resolved manager; read synchronously by the auth middleware and WebSocket config. */
167+
oidcManager?: MzOidcUserManager;
168+
/** In-flight init promise; awaited by OidcProviderWrapper to gate rendering. */
169+
oidcManagerInitializationPromise?: Promise<MzOidcUserManager>;
165170
authMode: SelfManagedAuthMode;
166171
authApiBasePath: string;
167172
mzHttpUrlScheme: HttpScheme;
@@ -179,17 +184,59 @@ export class SelfManagedApiClient
179184
return response;
180185
};
181186

187+
#oidcAuthMiddleware: Middleware = (next) => {
188+
return async (...fetchArgs) => {
189+
const [input, options = {}] = fetchArgs;
190+
const idToken = this.oidcManager?.getIdToken();
191+
192+
const headers = copyHeaders(fetchArgs);
193+
if (idToken) {
194+
headers.set("Authorization", `Bearer ${idToken}`);
195+
}
196+
197+
const request = new Request(input, { ...options, headers });
198+
return next(request);
199+
};
200+
};
201+
182202
constructor({ appConfig }: { appConfig: Readonly<SelfManagedAppConfig> }) {
183203
this.#appConfig = appConfig;
184204
this.mzHttpUrlScheme = this.#appConfig.environmentdScheme;
185205
this.mzWebsocketUrlScheme = this.#appConfig.environmentdWebsocketScheme;
186206
this.authApiBasePath = `${this.#appConfig.environmentdScheme}://${this.#appConfig.environmentdConfig.environmentdHttpAddress}`;
187207
this.authMode = this.#appConfig.authMode;
188208

189-
this.mzApiFetch =
190-
this.authMode === "None" ? globalFetch : this.#mzApiWithAuthRedirect;
209+
if (this.authMode === "Oidc") {
210+
this.oidcManagerInitializationPromise = MzOidcUserManager.create().then(
211+
(manager) => {
212+
this.oidcManager = manager;
213+
return manager;
214+
},
215+
);
216+
// When OIDC is configured, users can authenticate via either OIDC or
217+
// password. The OIDC middleware adds a Bearer token if one exists;
218+
// otherwise no auth header is sent and the session cookie is used
219+
// implicitly. The 401 redirect handles expired/missing sessions.
220+
this.mzApiFetch = withMiddleware(
221+
this.#mzApiWithAuthRedirect,
222+
this.#oidcAuthMiddleware,
223+
);
224+
} else if (this.authMode === "None") {
225+
this.mzApiFetch = globalFetch;
226+
} else {
227+
this.mzApiFetch = this.#mzApiWithAuthRedirect;
228+
}
191229

192230
this.getWsAuthConfig = () => {
231+
if (this.authMode === "Oidc") {
232+
const idToken = this.oidcManager?.getIdToken();
233+
if (idToken) {
234+
return buildTokenAuthConfig(idToken);
235+
}
236+
// No OIDC token — user authenticated via password, session cookie
237+
// is sent implicitly so no explicit auth config is needed.
238+
return null;
239+
}
193240
// Unintuitively, we return an auth config when authMode is "None". This is because
194241
// the authenticated websocket API gets the necessary information via the http-only cookie
195242
// and errors if you try to send a websocket message with the auth config.

console/src/api/materialize/auth.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,9 @@ interface LoginRequest {
1919
const getApiClient = () => {
2020
if (
2121
apiClient.type !== "self-managed" ||
22-
(apiClient.authMode !== "Password" && apiClient.authMode !== "Sasl")
22+
(apiClient.authMode !== "Password" &&
23+
apiClient.authMode !== "Sasl" &&
24+
apiClient.authMode !== "Oidc")
2325
) {
2426
throw new Error(NOT_SUPPORTED_MESSAGE);
2527
}
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
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 { useQuery } from "@tanstack/react-query";
11+
import React, { useCallback } from "react";
12+
import { useNavigate } from "react-router-dom";
13+
14+
import { apiClient } from "~/api/apiClient";
15+
import Alert from "~/components/Alert";
16+
import LoadingScreen from "~/components/LoadingScreen";
17+
import { useAppConfig } from "~/config/useAppConfig";
18+
import { AuthProvider } from "~/external-library-wrappers/oidc";
19+
20+
export const OidcProviderWrapper = ({ children }: React.PropsWithChildren) => {
21+
const navigate = useNavigate();
22+
const appConfig = useAppConfig();
23+
24+
const isOidc =
25+
appConfig.mode === "self-managed" && appConfig.authMode === "Oidc";
26+
27+
// Not a typical data fetch — using React Query to get loading/error
28+
// state without wiring up useState + useEffect manually.
29+
const {
30+
data: oidcManager,
31+
isLoading,
32+
error,
33+
} = useQuery({
34+
queryKey: ["oidc-manager"],
35+
queryFn: () => {
36+
if (
37+
apiClient.type !== "self-managed" ||
38+
!apiClient.oidcManagerInitializationPromise
39+
) {
40+
return null;
41+
}
42+
return apiClient.oidcManagerInitializationPromise;
43+
},
44+
enabled: isOidc,
45+
staleTime: Infinity,
46+
retry: false,
47+
});
48+
49+
const onSigninCallback = useCallback(() => {
50+
navigate("/", { replace: true });
51+
}, [navigate]);
52+
53+
if (!isOidc) {
54+
return children;
55+
}
56+
57+
if (isLoading) {
58+
return <LoadingScreen />;
59+
}
60+
61+
if (error) {
62+
return (
63+
<Alert
64+
variant="error"
65+
message={`Failed to initialize OIDC: ${error.message}`}
66+
/>
67+
);
68+
}
69+
70+
if (!oidcManager) {
71+
return children;
72+
}
73+
74+
return (
75+
<AuthProvider
76+
userManager={oidcManager.getUserManager()}
77+
onSigninCallback={onSigninCallback}
78+
>
79+
{children}
80+
</AuthProvider>
81+
);
82+
};

console/src/config/AppConfig.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -68,17 +68,20 @@ type FronteggAuthMode = "Frontegg";
6868
type PasswordAuthMode = "Password";
6969
type NoneAuthMode = "None";
7070
type SaslAuthMode = "Sasl";
71+
type OidcAuthMode = "Oidc";
7172
type CloudAuthMode = FronteggAuthMode;
7273
export type SelfManagedAuthMode =
7374
| PasswordAuthMode
7475
| NoneAuthMode
75-
| SaslAuthMode;
76+
| SaslAuthMode
77+
| OidcAuthMode;
7678

7779
type AuthMode =
7880
| FronteggAuthMode
7981
| PasswordAuthMode
8082
| NoneAuthMode
81-
| SaslAuthMode;
83+
| SaslAuthMode
84+
| OidcAuthMode;
8285

8386
interface IBaseAppConfig {
8487
// Discriminant for the type of app config.
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
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+
export const useAuth = vi.fn(() => ({
11+
isAuthenticated: false,
12+
isLoading: false,
13+
user: null,
14+
signinRedirect: vi.fn(),
15+
signoutRedirect: vi.fn(),
16+
}));
17+
18+
export const AuthProvider = ({ children }: React.PropsWithChildren) => children;
19+
20+
export const hasAuthParams = vi.fn(() => false);
21+
22+
export class MzOidcUserManager {
23+
getIdToken = vi.fn(() => undefined);
24+
getUserManager = vi.fn();
25+
signoutRedirect = vi.fn();
26+
27+
static create = vi.fn(() => Promise.resolve(new MzOidcUserManager()));
28+
}

0 commit comments

Comments
 (0)