Skip to content

Commit

Permalink
feat: handle oidc refresh and revoke
Browse files Browse the repository at this point in the history
  • Loading branch information
huwshimi committed Jul 16, 2024
1 parent f1773e9 commit 2d90de8
Show file tree
Hide file tree
Showing 16 changed files with 307 additions and 49 deletions.
1 change: 1 addition & 0 deletions .eslintrc
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@
"default-case": 0,
"no-param-reassign": 0,
"no-case-declarations": 0,
"no-constant-condition": ["error", { "checkLoops": false }],
"prefer-destructuring": 0,
"react/no-unescaped-entities": 0,
"react/display-name": 0,
Expand Down
5 changes: 5 additions & 0 deletions src/consts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,8 @@ export const DARK_THEME = true;

// The date format used in datetime-local fields.
export const DATETIME_LOCAL = "yyyy-MM-dd'T'HH:mm";

// The interval at which the OIDC whoami endpoint is polled at (in milliseconds).
// This is set to 5 minutes as that is how long a token is valid for in JIMM, so
// if access is revoked this will poll and delete the cookie.
export const OIDC_POLL_INTERVAL = 5 * 60 * 1000;
9 changes: 8 additions & 1 deletion src/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { createRoot } from "react-dom/client";
import { Provider } from "react-redux";

import App from "components/App";
import { addWhoamiListener } from "juju/jimm/listeners";
import reduxStore from "store";
import { thunks as appThunks } from "store/app";
import { actions as generalActions } from "store/general";
Expand All @@ -15,6 +16,8 @@ import type { WindowConfig } from "types";

import packageJSON from "../package.json";

import { listenerMiddleware } from "./store/listenerMiddleware";

export const ROOT_ID = "root";
export const RECHECK_TIME = 500;
const appVersion = packageJSON.version;
Expand Down Expand Up @@ -146,8 +149,12 @@ function bootstrap() {
reduxStore.dispatch(generalActions.storeConfig(config));
reduxStore.dispatch(generalActions.storeVersion(appVersion));

if (config.authMethod === AuthMethod.OIDC) {
addWhoamiListener(listenerMiddleware.startListening);
}

if ([AuthMethod.CANDID, AuthMethod.OIDC].includes(config.authMethod)) {
// If using Candid authentication then try and connect automatically
// If using Candid or OIDC authentication then try and connect automatically
// If not then wait for the login UI to trigger this
reduxStore
.dispatch(appThunks.connectAndStartPolling())
Expand Down
149 changes: 149 additions & 0 deletions src/juju/jimm/listeners.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,149 @@
import type {
ListenerMiddlewareInstance,
UnknownAction,
EnhancedStore,
StoreEnhancer,
Tuple,
} from "@reduxjs/toolkit";
import {
createListenerMiddleware,
configureStore,
createSlice,
} from "@reduxjs/toolkit";
import { waitFor } from "@testing-library/react";
import { vi } from "vitest";

import { thunks as appThunks } from "store/app";
import { actions as generalActions } from "store/general";
import type { RootState, AppDispatch } from "store/store";
import { rootStateFactory } from "testing/factories";

import { endpoints } from "./api";
import {
addWhoamiListener,
pollWhoamiStart,
Label,
pollWhoamiStop,
} from "./listeners";

vi.mock("consts", () => {
return { OIDC_POLL_INTERVAL: 1 };
});

vi.mock("store/general", async () => {
const actual = await vi.importActual("store/general");
return {
...actual,
actions: {
storeLoginError: vi.fn(),
},
};
});

vi.mock("store/app/thunks", async () => {
const actual = await vi.importActual("store/app/thunks");
return {
...actual,
logOut: vi.fn(),
};
});

describe("listeners", () => {
let listenerMiddleware: ListenerMiddlewareInstance<
RootState,
AppDispatch,
unknown
>;
let store: EnhancedStore<
RootState,
UnknownAction,
Tuple<
[
StoreEnhancer<{
dispatch: AppDispatch;
}>,
StoreEnhancer,
]
>
>;

beforeEach(() => {
fetchMock.resetMocks();
listenerMiddleware = createListenerMiddleware<RootState, AppDispatch>();
const slice = createSlice({
name: "root",
initialState: rootStateFactory.withGeneralConfig().build(),
reducers: {},
});
store = configureStore({
reducer: slice.reducer,
middleware: (getDefaultMiddleware) => {
const middleware = getDefaultMiddleware();
middleware.push(listenerMiddleware.middleware);
return middleware;
},
});
addWhoamiListener(listenerMiddleware.startListening);
});

afterEach(() => {
listenerMiddleware.clearListeners();
});

it("starts polling when start action is dispatched", async () => {
fetchMock.mockResponseOnce(JSON.stringify({}), { status: 200 });
expect(global.fetch).not.toHaveBeenCalled();
store.dispatch(pollWhoamiStart());
await waitFor(() =>
expect(global.fetch).toHaveBeenCalledWith(endpoints.whoami),
);
});

it("handles user logged out", async () => {
vi.spyOn(store, "dispatch");
vi.spyOn(generalActions, "storeLoginError").mockReturnValue({
type: "general/storeLoginError",
payload: { wsControllerURL: "", error: "" },
});
fetchMock.mockResponseOnce(JSON.stringify({}), { status: 401 });
store.dispatch(pollWhoamiStart());
await waitFor(() =>
expect(generalActions.storeLoginError).toHaveBeenCalledWith({
error: Label.ERROR_LOGGED_OUT,
wsControllerURL: "wss://controller.example.com",
}),
);
await waitFor(() => expect(appThunks.logOut).toHaveBeenCalled());
});

it("handles errors", async () => {
vi.spyOn(store, "dispatch");
vi.spyOn(generalActions, "storeLoginError").mockReturnValue({
type: "general/storeLoginError",
payload: { wsControllerURL: "", error: "" },
});
fetchMock.mockResponseOnce(JSON.stringify({}), { status: 500 });
store.dispatch(pollWhoamiStart());
await waitFor(() =>
expect(generalActions.storeLoginError).toHaveBeenCalledWith({
error: Label.ERROR_AUTHENTICATION,
wsControllerURL: "wss://controller.example.com",
}),
);
await waitFor(() => expect(appThunks.logOut).toHaveBeenCalled());
});

it("does not display an error when stopping the listener", async () => {
vi.spyOn(store, "dispatch");
vi.spyOn(generalActions, "storeLoginError").mockReturnValue({
type: "general/storeLoginError",
payload: { wsControllerURL: "", error: "" },
});
fetchMock.mockResponseOnce(JSON.stringify({}), { status: 500 });
store.dispatch(pollWhoamiStart());
store.dispatch(pollWhoamiStop());
await waitFor(() =>
expect(generalActions.storeLoginError).not.toHaveBeenCalled(),
);
});
});
69 changes: 69 additions & 0 deletions src/juju/jimm/listeners.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import type { ListenerMiddlewareInstance } from "@reduxjs/toolkit";
import { createAction, TaskAbortError } from "@reduxjs/toolkit";

import { OIDC_POLL_INTERVAL } from "consts";
import { thunks as appThunks } from "store/app";
import { actions as generalActions } from "store/general";
import { getWSControllerURL } from "store/general/selectors";
import type { RootState, AppDispatch } from "store/store";
import { toErrorString } from "utils";

import { endpoints } from "./api";

export enum Label {
ERROR_AUTHENTICATION = "Authentication error.",
ERROR_LOGGED_OUT = "You have been logged out.",
}

export const pollWhoamiStart = createAction("jimm/pollWhoami/start");
export const pollWhoamiStop = createAction("jimm/pollWhoami/stop");

export const addWhoamiListener = (
startListening: ListenerMiddlewareInstance<
RootState,
AppDispatch,
unknown
>["startListening"],
) => {
startListening({
actionCreator: pollWhoamiStart,
effect: async (_action, listenerApi) => {
listenerApi.unsubscribe();
const pollingTask = listenerApi.fork(async (forkApi) => {
try {
while (true) {
await forkApi.delay(OIDC_POLL_INTERVAL);
const response = await forkApi.pause(fetch(endpoints.whoami));
// Handle the user no longer logged in:
if (response.status === 401 || response.status === 403) {
throw new Error(Label.ERROR_LOGGED_OUT);
}
// Handle all other API errors:
if (!response.ok) {
throw new Error(Label.ERROR_AUTHENTICATION);
}
}
} catch (error) {
if (error instanceof TaskAbortError) {
// Polling was aborted e.g. when clicking "log out". Don't display
// this to the user.
return;
}
const wsControllerURL = getWSControllerURL(listenerApi.getState());
if (wsControllerURL) {
listenerApi.dispatch(
generalActions.storeLoginError({
error: toErrorString(error),
wsControllerURL,
}),
);
}
// If the user no longer has access then clean up state and display the login screen.
await listenerApi.dispatch(appThunks.logOut());
}

Check warning on line 63 in src/juju/jimm/listeners.ts

View check run for this annotation

Codecov / codecov/patch

src/juju/jimm/listeners.ts#L63

Added line #L63 was not covered by tests
});
await listenerApi.condition(pollWhoamiStop.match);
pollingTask.cancel();
},
});
};
8 changes: 8 additions & 0 deletions src/juju/jimm/thunks.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,14 @@ describe("thunks", () => {
expect(unwrapResult(response)).toBeNull();
});

it("whoami handles non-authenticated user", async () => {
fetchMock.mockResponseOnce(JSON.stringify({}), { status: 401 });
const action = whoami();
const response = await action(vi.fn(), vi.fn(), null);
expect(global.fetch).toHaveBeenCalledWith(endpoints.whoami);
expect(unwrapResult(response)).toBeNull();
});

it("whoami unsuccessful requests", async () => {
fetchMock.mockResponse(JSON.stringify({}), { status: 500 });
const action = whoami();
Expand Down
2 changes: 1 addition & 1 deletion src/juju/jimm/thunks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ export const whoami = createAsyncThunk<
>("jimm/whoami", async () => {
try {
const response = await fetch(endpoints.whoami);
if (response.status === 403) {
if (response.status === 401 || response.status === 403) {
// The user is not authenticated so return null instead of throwing an error.
return null;
}
Expand Down
35 changes: 35 additions & 0 deletions src/pages/EntityDetails/EntityDetails.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -218,13 +218,19 @@ describe("Entity Details Container", () => {
});

it("shows the CLI in juju 2.9", async () => {
state.general.config = configFactory.build({
isJuju: true,
});
renderComponent(<EntityDetails />, { path, url, state });
await waitFor(() => {
expect(screen.queryByTestId("webcli")).toBeInTheDocument();
});
});

it("shows the CLI in juju higher than 2.9", async () => {
state.general.config = configFactory.build({
isJuju: true,
});
state.juju.modelWatcherData = {
abc123: modelWatcherModelDataFactory.build({
applications: {
Expand All @@ -244,7 +250,33 @@ describe("Entity Details Container", () => {
});
});

it("does not show the CLI in JAAS", async () => {
state.general.config = configFactory.build({
isJuju: false,
});
state.juju.modelWatcherData = {
abc123: modelWatcherModelDataFactory.build({
applications: {
"ceph-mon": applicationInfoFactory.build(),
},
model: modelWatcherModelInfoFactory.build({
name: "enterprise",
owner: "kirk@external",
version: "3.0.7",
"controller-uuid": "controller123",
}),
}),
};
renderComponent(<EntityDetails />, { path, url, state });
await waitFor(() => {
expect(screen.queryByTestId("webcli")).not.toBeInTheDocument();
});
});

it("does not show the webCLI in juju 2.8", async () => {
state.general.config = configFactory.build({
isJuju: true,
});
state.juju.modelWatcherData = {
abc123: modelWatcherModelDataFactory.build({
applications: {
Expand All @@ -264,6 +296,9 @@ describe("Entity Details Container", () => {
});

it("passes the controller details to the webCLI", () => {
state.general.config = configFactory.build({
isJuju: true,
});
const cliComponent = vi
.spyOn(WebCLIModule, "default")
.mockImplementation(vi.fn());
Expand Down
4 changes: 2 additions & 2 deletions src/pages/EntityDetails/EntityDetails.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -99,14 +99,14 @@ const EntityDetails = ({ modelWatcherError }: Props) => {
};

useEffect(() => {
if (getMajorMinorVersion(modelInfo?.version) >= 2.9) {
if (isJuju && getMajorMinorVersion(modelInfo?.version) >= 2.9) {
// The Web CLI is only available in Juju controller versions 2.9 and
// above. This will allow us to only show the shell on multi-controller
// setups with different versions where the correct controller version
// is available.
setShowWebCLI(true);
}
}, [modelInfo]);
}, [modelInfo, isJuju]);

useWindowTitle(modelInfo?.name ? `Model: ${modelInfo?.name}` : "...");

Expand Down
Loading

0 comments on commit 2d90de8

Please sign in to comment.