Skip to content

Commit

Permalink
Merge remote-tracking branch 'origin/main' into koonpeng/merge-react-…
Browse files Browse the repository at this point in the history
…components

Signed-off-by: Teo Koon Peng <teokoonpeng@gmail.com>
  • Loading branch information
koonpeng committed Sep 19, 2024
2 parents 3b4c1e9 + 5d670b5 commit b8a6ebc
Show file tree
Hide file tree
Showing 23 changed files with 575 additions and 280 deletions.
3 changes: 2 additions & 1 deletion packages/api-server/api_server/app_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,9 +17,10 @@ class AppConfig:
log_level: str
builtin_admin: str
jwt_public_key: str | None
jwt_secret: str | None
oidc_url: str | None
aud: str
iss: str | None
iss: str
ros_args: list[str]
timezone: str

Expand Down
78 changes: 28 additions & 50 deletions packages/api-server/api_server/authenticator.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,8 @@
import base64
import json
import logging
from typing import Any, Callable, Coroutine, Protocol

import jwt
from fastapi import Depends, Header, HTTPException
import jwt.algorithms
from fastapi import Depends, HTTPException
from fastapi.security import OpenIdConnect

from .app_config import app_config
Expand All @@ -22,9 +20,12 @@ def fastapi_dep(self) -> Callable[..., Coroutine[Any, Any, User] | User]: ...


class JwtAuthenticator:
_algorithms = jwt.algorithms.get_default_algorithms()
del _algorithms["none"]

def __init__(
self,
pem_file: str,
key_or_secret: "jwt.algorithms.AllowedPublicKeys | str | bytes",
aud: str,
iss: str,
*,
Expand All @@ -38,8 +39,7 @@ def __init__(
self.aud = aud
self.iss = iss
self.oidc_url = oidc_url
with open(pem_file, "r", encoding="utf8") as f:
self._public_key = f.read()
self._key_or_secret = key_or_secret

async def _get_user(self, claims: dict) -> User:
if not "preferred_username" in claims:
Expand All @@ -48,18 +48,10 @@ async def _get_user(self, claims: dict) -> User:
)

username = claims["preferred_username"]
# FIXME(koonpeng): We should use the "userId" as the identifier. Some idP may allow
# duplicated usernames.
user = await User.load_or_create_from_db(username)

is_admin = False
if "realm_access" in claims:
if "roles" in claims["realm_access"]:
roles = claims["realm_access"]["roles"]
if "superuser" in roles:
is_admin = True

if user.is_admin != is_admin:
await user.update_admin(is_admin)

return user

async def verify_token(self, token: str | None) -> User:
Expand All @@ -68,15 +60,16 @@ async def verify_token(self, token: str | None) -> User:
try:
claims = jwt.decode(
token,
self._public_key,
algorithms=["RS256"],
self._key_or_secret,
algorithms=list(self._algorithms),
audience=self.aud,
issuer=self.iss,
)
user = await self._get_user(claims)

return user
except jwt.InvalidTokenError as e:
print(e)
raise AuthenticationError(str(e)) from e

def fastapi_dep(self) -> Callable[..., Coroutine[Any, Any, User] | User]:
Expand All @@ -94,45 +87,30 @@ async def dep(
return dep


class StubAuthenticator(Authenticator):
"""
StubAuthenticator will authenticate as an admin user called "stub" if no tokens are
present. If there is a bearer token in the `Authorization` header, then it decodes the jwt
WITHOUT verifying the signature and authenticated as the user given.
"""

async def verify_token(self, token: str | None):
if not token:
return User(username="stub", is_admin=True)
# decode the jwt without verifying signature
parts = token.split(".")
# add padding to ignore incorrect padding errors
payload = base64.b64decode(parts[1] + "==")
username = json.loads(payload)["preferred_username"]
return await User.load_or_create_from_db(username)

def fastapi_dep(self):
async def dep(authorization: str | None = Header(None)):
if not authorization:
return await self.verify_token(None)
token = authorization.split(" ")[1]
return await self.verify_token(token)

return dep

if app_config.jwt_public_key and app_config.jwt_secret:
raise ValueError("only one of jwt_public_key or jwt_secret must be set")
if not app_config.iss:
raise ValueError("iss is required")
if not app_config.aud:
raise ValueError("aud is required")

if app_config.jwt_public_key:
if app_config.iss is None:
raise ValueError("iss is required")
with open(app_config.jwt_public_key, "br") as f:
authenticator = JwtAuthenticator(
f.read(),
app_config.aud,
app_config.iss,
oidc_url=app_config.oidc_url or "",
)
elif app_config.jwt_secret:
authenticator = JwtAuthenticator(
app_config.jwt_public_key,
app_config.jwt_secret,
app_config.aud,
app_config.iss,
oidc_url=app_config.oidc_url or "",
)
else:
authenticator = StubAuthenticator()
logging.warning("authentication is disabled")
raise ValueError("either jwt_public_key or jwt_secret is required")


user_dep = authenticator.fastapi_dep()
5 changes: 3 additions & 2 deletions packages/api-server/api_server/default_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
"builtin_admin": "admin",
# path to a PEM encoded RSA public key which is used to verify JWT tokens, if the path is relative, it is based on the working dir.
"jwt_public_key": None,
# jwt secret, this is mutually exclusive with `jwt_public_key`.
"jwt_secret": "rmfisawesome",
# url to the oidc endpoint, used to authenticate rest requests, it should point to the well known endpoint, e.g.
# http://localhost:8080/auth/realms/rmf-web/.well-known/openid-configuration.
# NOTE: This is ONLY used for documentation purposes, the "jwt_public_key" will be the
Expand All @@ -26,8 +28,7 @@
"aud": "rmf_api_server",
# url or string that identifies the entity that issued the jwt token
# Used to verify the "iss" claim
# If iss is set to None, it means that authentication should be disabled
"iss": None,
"iss": "stub",
# list of arguments passed to the ros node, "--ros-args" is automatically prepended to the list.
# e.g.
# Run with sim time: ["-p", "use_sim_time:=true"]
Expand Down
3 changes: 3 additions & 0 deletions packages/api-server/api_server/models/user.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,9 @@


class User(PydanticModel):
# FIXME(koonpeng): We should use the "userId" as the identifier. Some idP may allow
# duplicated usernames.
# userId: str
username: str
is_admin: bool = False
roles: list[str] = []
Expand Down
1 change: 1 addition & 0 deletions packages/api-server/scripts/test_config.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"port": int(test_port),
"log_level": "ERROR",
"jwt_public_key": f"{here}/test.pub",
"jwt_secret": None,
"iss": "test",
"db_url": os.environ.get("RMF_API_SERVER_TEST_DB_URL", "sqlite://:memory:"),
"timezone": "Asia/Singapore",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,7 +59,7 @@ describe('AppBar', () => {
});

it('logout is triggered when logout button is clicked', async () => {
const authenticator = new StubAuthenticator('test');
const authenticator = new StubAuthenticator();
const spy = vi.spyOn(authenticator, 'logout').mockImplementation(() => undefined as any);
const root = render(
<Base>
Expand Down
63 changes: 27 additions & 36 deletions packages/rmf-dashboard-framework/src/components/appbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -47,16 +47,15 @@ import { Subscription } from 'rxjs';

import { useAppController } from '../hooks/use-app-controller';
import { useAuthenticator } from '../hooks/use-authenticator';
import { useCreateTaskFormData } from '../hooks/use-create-task-form';
import { useTaskFormData } from '../hooks/use-create-task-form';
import { useResources } from '../hooks/use-resources';
import { useRmfApi } from '../hooks/use-rmf-api';
import { useSettings } from '../hooks/use-settings';
import { useTaskRegistry } from '../hooks/use-task-registry';
import { useUserProfile } from '../hooks/use-user-profile';
import { AppEvents } from './app-events';
import { ConfirmationDialog } from './confirmation-dialog';
import { CreateTaskForm, CreateTaskFormProps } from './tasks/create-task';
import { toApiSchedule } from './tasks/utils';
import { dispatchTask, scheduleTask, TaskForm, TaskFormProps } from './tasks';
import { DashboardThemes } from './theme';

export const APP_BAR_HEIGHT = '3.5rem';
Expand Down Expand Up @@ -117,8 +116,8 @@ export const AppBar = React.memo(
FireAlarmTriggerState | undefined
>(undefined);

const { waypointNames, pickupPoints, dropoffPoints, cleaningZoneNames } =
useCreateTaskFormData(rmfApi);
const { waypointNames, pickupPoints, dropoffPoints, cleaningZoneNames, fleets } =
useTaskFormData(rmfApi);
const username = profile.user.username;

async function handleLogout(): Promise<void> {
Expand Down Expand Up @@ -148,30 +147,23 @@ export const AppBar = React.memo(
return () => subs.forEach((s) => s.unsubscribe());
}, [rmfApi]);

const submitTasks = React.useCallback<Required<CreateTaskFormProps>['submitTasks']>(
async (taskRequests, schedule) => {
if (!schedule) {
await Promise.all(
taskRequests.map((request) => {
console.debug('submitTask:');
console.debug(request);
return rmfApi.tasksApi.postDispatchTaskTasksDispatchTaskPost({
type: 'dispatch_task_request',
request,
});
}),
);
} else {
const scheduleRequests = taskRequests.map((req) => {
console.debug('schedule task:');
console.debug(req);
console.debug(schedule);
return toApiSchedule(req, schedule);
});
await Promise.all(
scheduleRequests.map((req) => rmfApi.tasksApi.postScheduledTaskScheduledTasksPost(req)),
);
const dispatchTaskCallback = React.useCallback<Required<TaskFormProps>['onDispatchTask']>(
async (taskRequest, robotDispatchTarget) => {
if (!rmfApi) {
throw new Error('tasks api not available');
}
await dispatchTask(rmfApi, taskRequest, robotDispatchTarget);
AppEvents.refreshTaskApp.next();
},
[rmfApi],
);

const scheduleTaskCallback = React.useCallback<Required<TaskFormProps>['onScheduleTask']>(
async (taskRequest, schedule) => {
if (!rmfApi) {
throw new Error('tasks api not available');
}
await scheduleTask(rmfApi, taskRequest, schedule);
AppEvents.refreshTaskApp.next();
},
[rmfApi],
Expand All @@ -190,19 +182,15 @@ export const AppBar = React.memo(
return () => sub.unsubscribe();
}, [rmfApi]);

const submitFavoriteTask = React.useCallback<
Required<CreateTaskFormProps>['submitFavoriteTask']
>(
const submitFavoriteTask = React.useCallback<Required<TaskFormProps>['submitFavoriteTask']>(
async (taskFavoriteRequest) => {
await rmfApi.tasksApi.postFavoriteTaskFavoriteTasksPost(taskFavoriteRequest);
AppEvents.refreshFavoriteTasks.next();
},
[rmfApi],
);

const deleteFavoriteTask = React.useCallback<
Required<CreateTaskFormProps>['deleteFavoriteTask']
>(
const deleteFavoriteTask = React.useCallback<Required<TaskFormProps>['deleteFavoriteTask']>(
async (favoriteTask) => {
if (!favoriteTask.id) {
throw new Error('Id is needed');
Expand Down Expand Up @@ -465,8 +453,9 @@ export const AppBar = React.memo(
</Menu>

{openCreateTaskForm && (
<CreateTaskForm
<TaskForm
user={username ? username : 'unknown user'}
fleets={fleets}
tasksToDisplay={taskRegistry.taskDefinitions}
patrolWaypoints={waypointNames}
cleaningZones={cleaningZoneNames}
Expand All @@ -477,7 +466,9 @@ export const AppBar = React.memo(
favoritesTasks={favoritesTasks}
open={openCreateTaskForm}
onClose={() => setOpenCreateTaskForm(false)}
submitTasks={submitTasks}
onDispatchTask={dispatchTaskCallback}
onScheduleTask={scheduleTaskCallback}
onEditScheduleTask={undefined}
submitFavoriteTask={submitFavoriteTask}
deleteFavoriteTask={deleteFavoriteTask}
onSuccess={() => {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -274,7 +274,7 @@ function DashboardContents({
extraAppbarItems,
}: DashboardContentsProps) {
const location = useLocation();
const currentTab = tabs.find((t) => matchPath(t.route, location.pathname));
const currentTab = tabs.find((t) => matchPath(`${baseUrl}${t.route}`, location.pathname));

const [pendingTransition, startTransition] = useTransition();
const navigate = useNavigate();
Expand Down Expand Up @@ -334,6 +334,7 @@ function DashboardContents({
/>
))}
</Route>
<Route path="*" element={<NotFound />} />
</Route>
<Route path="*" element={<NotFound />} />
</Routes>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
export * from './task-booking-label-utils';
export * from './task-cancellation';
export * from './task-details-card';
export * from './task-form';
export * from './task-info';
export * from './task-inspector';
export * from './task-logs';
Expand All @@ -10,3 +11,4 @@ export * from './task-summary';
export * from './task-table';
export * from './task-timeline';
export * from './tasks-window';
export * from './utils';
Original file line number Diff line number Diff line change
@@ -1,23 +1,24 @@
import { Meta, StoryObj } from '@storybook/react';

import { CreateTaskForm } from './create-task';
import { TaskForm } from './task-form';

export default {
title: 'Tasks/Create Task',
component: CreateTaskForm,
component: TaskForm,
} satisfies Meta;

type Story = StoryObj<typeof CreateTaskForm>;
type Story = StoryObj<typeof TaskForm>;

export const CreateTask: Story = {
export const OpenTaskForm: Story = {
args: {
submitTasks: async () => new Promise((res) => setTimeout(res, 500)),
onDispatchTask: async () => new Promise((res) => setTimeout(res, 500)),
onScheduleTask: async () => new Promise((res) => setTimeout(res, 500)),
cleaningZones: ['test_zone_0', 'test_zone_1'],
patrolWaypoints: ['test_waypoint_0', 'test_waypoint_1'],
pickupPoints: { test_waypoint_0: 'test_waypoint_0' },
dropoffPoints: { test_waypoint_1: 'test_waypoint_1' },
},
render: (args) => {
return <CreateTaskForm {...args} open></CreateTaskForm>;
return <TaskForm {...args} open></TaskForm>;
},
};
Loading

0 comments on commit b8a6ebc

Please sign in to comment.