diff --git a/packages/api-server/api_server/app_config.py b/packages/api-server/api_server/app_config.py index 6a6618465..40841997f 100644 --- a/packages/api-server/api_server/app_config.py +++ b/packages/api-server/api_server/app_config.py @@ -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 diff --git a/packages/api-server/api_server/authenticator.py b/packages/api-server/api_server/authenticator.py index 536beaf6e..16fff7802 100644 --- a/packages/api-server/api_server/authenticator.py +++ b/packages/api-server/api_server/authenticator.py @@ -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 @@ -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, *, @@ -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: @@ -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: @@ -68,8 +60,8 @@ 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, ) @@ -77,6 +69,7 @@ async def verify_token(self, token: str | None) -> User: 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]: @@ -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() diff --git a/packages/api-server/api_server/default_config.py b/packages/api-server/api_server/default_config.py index 76780d80d..f7ea6e466 100644 --- a/packages/api-server/api_server/default_config.py +++ b/packages/api-server/api_server/default_config.py @@ -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 @@ -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"] diff --git a/packages/api-server/api_server/models/user.py b/packages/api-server/api_server/models/user.py index 59d7d17b2..b5c630fa8 100644 --- a/packages/api-server/api_server/models/user.py +++ b/packages/api-server/api_server/models/user.py @@ -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] = [] diff --git a/packages/api-server/scripts/test_config.py b/packages/api-server/scripts/test_config.py index ada8fd2c3..ece999981 100644 --- a/packages/api-server/scripts/test_config.py +++ b/packages/api-server/scripts/test_config.py @@ -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", diff --git a/packages/dashboard/src/components/appbar.test.tsx b/packages/dashboard/src/components/appbar.test.tsx index ab5212a91..c18015df5 100644 --- a/packages/dashboard/src/components/appbar.test.tsx +++ b/packages/dashboard/src/components/appbar.test.tsx @@ -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( diff --git a/packages/dashboard/src/services/stub-authenticator.ts b/packages/dashboard/src/services/stub-authenticator.ts index 30e6898db..f787dedc2 100644 --- a/packages/dashboard/src/services/stub-authenticator.ts +++ b/packages/dashboard/src/services/stub-authenticator.ts @@ -2,6 +2,33 @@ import EventEmitter from 'eventemitter3'; import { Authenticator, AuthenticatorEventType } from './authenticator'; +/** + * Hardcoded token using the secret 'rmfisawesome', expires in 2035-01-01. + * To update the token, use https://jwt.io and paste in the payload, also remember + * to set the secret to `rmfisawesome`. + * + * header: + * { + * "alg": "HS256", + * "typ": "JWT" + * } + * payload: + * { + * "sub": "stub", + * "preferred_username": "admin", + * "iat": 1516239022, + * "aud": "rmf_api_server", + * "iss": "stub", + * "exp": 2051222400 + * } + */ +const ADMIN_TOKEN = + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJzdHViIiwicHJlZmVycmVkX3VzZXJuYW1lIjoiYWRtaW4iLCJpYXQiOjE1MTYyMzkwMjIsImF1ZCI6InJtZl9hcGlfc2VydmVyIiwiaXNzIjoic3R1YiIsImV4cCI6MjA1MTIyMjQwMH0.zzX3zXp467ldkzmLVIadQ_AHr8M5uWVV43n4wEB0OhE'; + +// same as the admin token, except the `preferred_username` is "user". +const USER_TOKEN = + 'eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJzdHViIiwicHJlZmVycmVkX3VzZXJuYW1lIjoidXNlciIsImlhdCI6MTUxNjIzOTAyMiwiYXVkIjoicm1mX2FwaV9zZXJ2ZXIiLCJpc3MiOiJzdHViIiwiZXhwIjoyMDUxMjIyNDAwfQ.vK3n4FbshCykQ9BW49w_7AfqKgbN9j2R3-Qh-rIOt_g'; + export class StubAuthenticator extends EventEmitter implements Authenticator @@ -10,10 +37,10 @@ export class StubAuthenticator readonly token?: string; - constructor(user = 'stub', token: string | undefined = undefined) { + constructor(isAdmin = true) { super(); - this.user = user; - this.token = token; + this.user = isAdmin ? 'admin' : 'user'; + this.token = isAdmin ? ADMIN_TOKEN : USER_TOKEN; } init(): Promise { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9568a5a43..d550aa5b4 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -230,7 +230,7 @@ importers: version: 2.0.4(vitest@2.0.4(@types/node@20.14.12)(jsdom@24.1.1(canvas@2.11.2))(terser@5.31.6)) api-server: specifier: file:../api-server - version: link:../api-server + version: file:packages/api-server concurrently: specifier: ^8.2.2 version: 8.2.2 @@ -3226,6 +3226,9 @@ packages: resolution: {integrity: sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==} engines: {node: '>= 8'} + api-server@file:packages/api-server: + resolution: {directory: packages/api-server, type: directory} + aproba@2.0.0: resolution: {integrity: sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ==} @@ -8218,7 +8221,6 @@ snapshots: '@babel/parser@7.25.3': dependencies: '@babel/types': 7.25.2 - optional: true '@babel/plugin-bugfix-firefox-class-in-computed-class-key@7.24.7(@babel/core@7.24.8)': dependencies: @@ -8987,7 +8989,6 @@ snapshots: '@babel/helper-string-parser': 7.24.8 '@babel/helper-validator-identifier': 7.24.7 to-fast-properties: 2.0.0 - optional: true '@base2/pretty-print-object@1.0.1': {} @@ -9461,7 +9462,7 @@ snapshots: '@jest/schemas': 28.1.3 '@types/istanbul-lib-coverage': 2.0.6 '@types/istanbul-reports': 3.0.4 - '@types/node': 20.14.10 + '@types/node': 22.2.0 '@types/yargs': 17.0.32 chalk: 4.1.2 @@ -10741,24 +10742,24 @@ snapshots: '@types/babel__core@7.20.5': dependencies: - '@babel/parser': 7.24.8 - '@babel/types': 7.24.9 + '@babel/parser': 7.25.3 + '@babel/types': 7.25.2 '@types/babel__generator': 7.6.8 '@types/babel__template': 7.4.4 '@types/babel__traverse': 7.20.6 '@types/babel__generator@7.6.8': dependencies: - '@babel/types': 7.24.9 + '@babel/types': 7.25.2 '@types/babel__template@7.4.4': dependencies: - '@babel/parser': 7.24.8 - '@babel/types': 7.24.9 + '@babel/parser': 7.25.3 + '@babel/types': 7.25.2 '@types/babel__traverse@7.20.6': dependencies: - '@babel/types': 7.24.9 + '@babel/types': 7.25.2 '@types/body-parser@1.19.5': dependencies: @@ -10774,7 +10775,7 @@ snapshots: '@types/connect@3.4.38': dependencies: - '@types/node': 20.14.10 + '@types/node': 22.2.0 '@types/crc@3.8.3': dependencies: @@ -10995,7 +10996,7 @@ snapshots: '@types/send@0.17.4': dependencies: '@types/mime': 1.3.5 - '@types/node': 20.14.10 + '@types/node': 22.2.0 '@types/serve-static@1.15.7': dependencies: @@ -11618,6 +11619,8 @@ snapshots: normalize-path: 3.0.0 picomatch: 2.3.1 + api-server@file:packages/api-server: {} + aproba@2.0.0: {} arch@2.2.0: {} @@ -12183,7 +12186,7 @@ snapshots: chrome-launcher@0.14.2: dependencies: - '@types/node': 15.14.9 + '@types/node': 22.2.0 escape-string-regexp: 4.0.0 is-wsl: 2.2.0 lighthouse-logger: 1.4.2