From 9aadf967f0e8f932589a549f2d2e62422df9b876 Mon Sep 17 00:00:00 2001 From: "Ware, Joseph (DLSLtd,RAL,LSCI)" Date: Mon, 13 Jan 2025 18:16:03 +0000 Subject: [PATCH] Almost-working example using the mock-oidc-server for test and development purposes. --- example_configs/mock-oidc-server.yml | 15 +++ oidc/clients-config.json | 28 +++++ oidc/oidc-docker-compose.yaml | 69 +++++++++++ tiled/commandline/_serve.py | 10 +- tiled/server/app.py | 10 +- tiled/server/authentication.py | 175 --------------------------- tiled/server/dependencies.py | 11 +- tiled/server/metrics.py | 3 +- tiled/server/router.py | 7 +- 9 files changed, 123 insertions(+), 205 deletions(-) create mode 100644 example_configs/mock-oidc-server.yml create mode 100644 oidc/clients-config.json create mode 100644 oidc/oidc-docker-compose.yaml diff --git a/example_configs/mock-oidc-server.yml b/example_configs/mock-oidc-server.yml new file mode 100644 index 000000000..c8531f4cd --- /dev/null +++ b/example_configs/mock-oidc-server.yml @@ -0,0 +1,15 @@ +authentication: + providers: + - provider: localhost + authenticator: tiled.authenticators:OIDCAuthenticator + args: + audience: tiled # something unique to ensure received headers are for you + # These values come from https://console.cloud.google.com/apis/credential + client_id: tiled + client_secret: secret + well_known_uri: http://localhost:8080/.well-known/openid-configuration +trees: + # Just some arbitrary example data... + # The point of this example is the authenticaiton above. + - tree: tiled.examples.generated_minimal:tree + path: / diff --git a/oidc/clients-config.json b/oidc/clients-config.json new file mode 100644 index 000000000..166ae4e8a --- /dev/null +++ b/oidc/clients-config.json @@ -0,0 +1,28 @@ +[ + { + "ClientId": "tiled", + "ClientSecrets": ["secret"], + "Description": "Tiled web interface login", + "AllowedGrantTypes": ["implicit"], + "AllowedScopes": ["tiled", "openid", "profile", "email"], + "RedirectUris": ["localhost:4000/*"], + "AllowAccessTokensViaBrowser": true, + "AccessTokenLifetime": 3600, + "IdentityTokenLifetime": 3600 + }, + { + "ClientId": "blueapi", + "ClientSecrets": ["secret"], + "Description": "Blueapi CLI login", + "AllowedGrantTypes": ["urn:ietf:params:oauth:grant-type:device_code"], + "AllowedScopes": ["blueapi", "openid", "profile", "email", "offline_access"], + "AccessTokenLifetime": 3600, + "IdentityTokenLifetime": 3600, + "AllowOfflineAccess": true, + "Claims": [{ + "Type": "aud", + "Value": "blueapi", + "ValueType": "string" + }] + } + ] \ No newline at end of file diff --git a/oidc/oidc-docker-compose.yaml b/oidc/oidc-docker-compose.yaml new file mode 100644 index 000000000..0dff7607a --- /dev/null +++ b/oidc/oidc-docker-compose.yaml @@ -0,0 +1,69 @@ +services: + oidc-server-mock: + container_name: oidc-server-mock + image: ghcr.io/soluto/oidc-server-mock:latest + ports: + - 8080:80 + environment: + ASPNETCORE_ENVIRONMENT: Development + ASPNETCORE_URLS: http://+:80 + # ASPNETCORE_Kestrel__Certificates__Default__Password: + # ASPNETCORE_Kestrel__Certificates__Default__Path: /path/to/pfx/file + SERVER_OPTIONS_INLINE: | + { + "AccessTokenJwtType": "JWT", + "Discovery": { + "ShowKeySet": true + }, + "Authentication": { + "CookieSameSiteMode": "Lax", + "CheckSessionCookieSameSiteMode": "Lax" + } + } + LOGIN_OPTIONS_INLINE: | + { + "AllowRememberLogin": false, + "AllowOfflineAccess": true + } + LOGOUT_OPTIONS_INLINE: | + { + "AutomaticRedirectAfterSignOut": true + } + API_SCOPES_INLINE: | + - Name: blueapi + - Name: tiled + API_RESOURCES_INLINE: | + - Name: app + Scopes: + - blueapi + - tiled + USERS_CONFIGURATION_INLINE: | + [ + { + "SubjectId":"1", + "Username":"user", + "Password":"password", + "Claims": [ + { + "Type": "name", + "Value": "Joe Bloggs", + "ValueType": "string" + }, + { + "Type": "email", + "Value": "joe.bloggs@diamond.ac.uk", + "ValueType": "string" + } + ] + } + ] + CLIENTS_CONFIGURATION_PATH: /tmp/config/clients-config.json + ASPNET_SERVICES_OPTIONS_INLINE: | + { + "BasePath": "/foo", + "ForwardedHeadersOptions": { + "ForwardedHeaders" : "All" + } + } + volumes: + - .:/tmp/config:ro \ No newline at end of file diff --git a/tiled/commandline/_serve.py b/tiled/commandline/_serve.py index 828bba1bf..d81be0a4d 100644 --- a/tiled/commandline/_serve.py +++ b/tiled/commandline/_serve.py @@ -98,7 +98,7 @@ def serve_directory( "example: --host `'::'`." ), ), - port: int = typer.Option(8000, help="Bind to a socket with this port."), + port: int = typer.Option(4000, help="Bind to a socket with this port."), log_config: Optional[str] = typer.Option( None, help="Custom uvicorn logging configuration file" ), @@ -335,7 +335,7 @@ def serve_catalog( "example: --host `'::'`." ), ), - port: int = typer.Option(8000, help="Bind to a socket with this port."), + port: int = typer.Option(4000, help="Bind to a socket with this port."), scalable: bool = typer.Option( False, "--scalable", @@ -499,7 +499,7 @@ def serve_pyobject( "example: --host `'::'`." ), ), - port: int = typer.Option(8000, help="Bind to a socket with this port."), + port: int = typer.Option(4000, help="Bind to a socket with this port."), scalable: bool = typer.Option( False, "--scalable", @@ -547,7 +547,7 @@ def serve_demo( "example: --host `'::'`." ), ), - port: int = typer.Option(8000, help="Bind to a socket with this port."), + port: int = typer.Option(4000, help="Bind to a socket with this port."), ): "Start a public server with example data." from ..server.app import build_app, print_admin_api_key_if_generated @@ -650,7 +650,7 @@ def serve_config( uvicorn_kwargs = parsed_config.pop("uvicorn", {}) # If --host is given, it overrides host in config. Same for --port and --log-config. uvicorn_kwargs["host"] = host or uvicorn_kwargs.get("host", "127.0.0.1") - uvicorn_kwargs["port"] = port or uvicorn_kwargs.get("port", 8000) + uvicorn_kwargs["port"] = port or uvicorn_kwargs.get("port", 4000) uvicorn_kwargs["log_config"] = _setup_log_config( log_config or uvicorn_kwargs.get("log_config"), log_timestamps, diff --git a/tiled/server/app.py b/tiled/server/app.py index 1f991d666..47461e266 100644 --- a/tiled/server/app.py +++ b/tiled/server/app.py @@ -42,7 +42,6 @@ from ..utils import SHARE_TILED_PATH, Conflicts, SpecialUsers, UnsupportedQueryType from ..validation_registration import validation_registry as default_validation_registry from . import schemas -from .authentication import get_current_principal from .compression import CompressionMiddleware from .dependencies import ( get_query_registry, @@ -267,7 +266,6 @@ async def index( request: Request, # This dependency is here because it runs the code that moves # API key from the query parameter to a cookie (if it is valid). - principal=Security(get_current_principal, scopes=[]), ): return templates.TemplateResponse( "index.html", @@ -368,14 +366,8 @@ async def unhandled_exception_handler( build_device_code_user_code_form_route, build_device_code_user_code_submit_route, build_handle_credentials_route, - oauth2_scheme, - ) - - # For the OpenAPI schema, inject a OAuth2PasswordBearer URL. - first_provider = authentication["providers"][0]["provider"] - oauth2_scheme.model.flows.password.tokenUrl = ( - f"/api/v1/auth/provider/{first_provider}/token" ) + # Authenticators provide Router(s) for their particular flow. # Collect them in the authentication_router. authentication_router = APIRouter() diff --git a/tiled/server/authentication.py b/tiled/server/authentication.py index 6b325fc2c..45fbff973 100644 --- a/tiled/server/authentication.py +++ b/tiled/server/authentication.py @@ -138,8 +138,6 @@ async def __call__(self, request: Request) -> Optional[str]: return param -# The tokenUrl below is patched at app startup when we know it. -oauth2_scheme = OAuth2PasswordBearer(tokenUrl="PLACEHOLDER", auto_error=False) api_key_query = APIKeyQuery(name="api_key", auto_error=False) api_key_header = APIKeyAuthorizationHeader( name="Authorization", @@ -215,169 +213,6 @@ def headers_for_401(request: Request, security_scopes: SecurityScopes): } return headers_for_401 - -async def get_decoded_access_token( - request: Request, - security_scopes: SecurityScopes, - access_token: str = Depends(oauth2_scheme), - settings: BaseSettings = Depends(get_settings), -): - if not access_token: - return None - try: - payload = decode_token(access_token, settings.secret_keys) - except ExpiredSignatureError: - raise HTTPException( - status_code=HTTP_401_UNAUTHORIZED, - detail="Access token has expired. Refresh token.", - headers=headers_for_401(request, security_scopes), - ) - return payload - - -async def get_session_state(decoded_access_token=Depends(get_decoded_access_token)): - if decoded_access_token: - return decoded_access_token.get("state") - - -async def get_current_principal( - request: Request, - security_scopes: SecurityScopes, - decoded_access_token: str = Depends(get_decoded_access_token), - api_key: str = Depends(get_api_key), - settings: BaseSettings = Depends(get_settings), - authenticators=Depends(get_authenticators), - db=Depends(get_database_session), -): - """ - Get current Principal from: - - API key in 'api_key' query parameter - - API key in header 'Authorization: Apikey ...' - - API key in cookie 'tiled_api_key' - - OAuth2 JWT access token in header 'Authorization: Bearer ...' - - Fall back to SpecialUsers.public, if anonymous access is allowed - If this server is configured with a "single-user API key", then - the Principal will be SpecialUsers.admin always. - """ - - if api_key is not None: - if authenticators: - # Tiled is in a multi-user configuration with authentication providers. - # We store the hashed value of the API key secret. - # By comparing hashes we protect against timing attacks. - # By storing only the hash of the (high-entropy) secret - # we reduce the value of that an attacker can extracted from a - # stolen database backup. - try: - secret = bytes.fromhex(api_key) - except Exception: - # Not valid hex, therefore not a valid API key - raise HTTPException( - status_code=HTTP_401_UNAUTHORIZED, - detail="Invalid API key", - headers=headers_for_401(request, security_scopes), - ) - api_key_orm = await lookup_valid_api_key(db, secret) - if api_key_orm is not None: - principal = api_key_orm.principal - principal_scopes = set().union( - *[role.scopes for role in principal.roles] - ) - # This intersection addresses the case where the Principal has - # lost a scope that they had when this key was created. - scopes = set(api_key_orm.scopes).intersection( - principal_scopes | {"inherit"} - ) - if "inherit" in scopes: - # The scope "inherit" is a metascope that confers all the - # scopes for the Principal associated with this API, - # resolved at access time. - scopes.update(principal_scopes) - api_key_orm.latest_activity = utcnow() - await db.commit() - else: - raise HTTPException( - status_code=HTTP_401_UNAUTHORIZED, - detail="Invalid API key", - headers=headers_for_401(request, security_scopes), - ) - else: - # Tiled is in a "single user" mode with only one API key. - if secrets.compare_digest(api_key, settings.single_user_api_key): - principal = SpecialUsers.admin - scopes = { - "read:metadata", - "read:data", - "write:metadata", - "write:data", - "create", - "register", - "metrics", - } - else: - raise HTTPException( - status_code=HTTP_401_UNAUTHORIZED, - detail="Invalid API key", - headers=headers_for_401(request, security_scopes), - ) - # If we made it to this point, we have a valid API key. - # If the API key was given in query param, move to cookie. - # This is convenient for browser-based access. - if ("api_key" in request.query_params) and ( - request.cookies.get(API_KEY_COOKIE_NAME) != api_key - ): - request.state.cookies_to_set.append( - {"key": API_KEY_COOKIE_NAME, "value": api_key} - ) - elif decoded_access_token is not None: - principal = schemas.Principal( - uuid=uuid_module.UUID(hex=decoded_access_token["sub"]), - type=decoded_access_token["sub_typ"], - identities=[ - schemas.Identity(id=identity["id"], provider=identity["idp"]) - for identity in decoded_access_token["ids"] - ], - ) - scopes = decoded_access_token["scp"] - else: - # No form of authentication is present. - principal = SpecialUsers.public - # Is anonymous public access permitted? - if settings.allow_anonymous_access: - # Any user who can see the server can make unauthenticated requests. - # This is a sentinel that has special meaning to the authorization - # code (the access control policies). - scopes = {"read:metadata", "read:data"} - else: - # In this mode, there may still be entries that are visible to all, - # but users have to authenticate as *someone* to see anything. - # They can still access the / and /docs routes. - scopes = {} - # Scope enforcement happens here. - # https://fastapi.tiangolo.com/advanced/security/oauth2-scopes/ - if not set(security_scopes.scopes).issubset(scopes): - # Include a link to the root page which provides a list of - # authenticators. The use case here is: - # 1. User is emailed a link like https://example.com/subpath//metadata/a/b/c - # 2. Tiled Client tries to connect to that and gets 401. - # 3. Client can use this header to find its way to - # https://examples.com/subpath/ and obtain a list of - # authentication providers and endpoints. - raise HTTPException( - status_code=HTTP_401_UNAUTHORIZED, - detail=( - "Not enough permissions. " - f"Requires scopes {security_scopes.scopes}. " - f"Request had scopes {list(scopes)}" - ), - headers=headers_for_401(request, security_scopes), - ) - # This is used to pass the currently-authenticated principal into the logger. - request.state.principal = principal - return principal - - async def create_pending_session(db): device_code = secrets.token_bytes(32) hashed_device_code = hashlib.sha256(device_code).digest() @@ -811,7 +646,6 @@ async def principal_list( limit: Optional[int] = Query( DEFAULT_PAGE_SIZE, alias="page[limit]", ge=0, le=MAX_PAGE_SIZE ), - principal=Security(get_current_principal, scopes=["read:principals"]), db=Depends(get_database_session), ): "List Principals (users and services)." @@ -849,7 +683,6 @@ async def principal_list( ) async def create_service_principal( request: Request, - principal=Security(get_current_principal, scopes=["write:principals"]), db=Depends(get_database_session), role: str = Query(...), ): @@ -884,7 +717,6 @@ async def create_service_principal( async def principal( request: Request, uuid: uuid_module.UUID, - principal=Security(get_current_principal, scopes=["read:principals"]), db=Depends(get_database_session), ): "Get information about one Principal (user or service)." @@ -920,7 +752,6 @@ async def revoke_apikey_for_principal( request: Request, uuid: uuid_module.UUID, first_eight: str, - principal=Security(get_current_principal, scopes=["admin:apikeys"]), db=Depends(get_database_session), ): "Allow Tiled Admins to delete any user's apikeys e.g." @@ -949,7 +780,6 @@ async def apikey_for_principal( request: Request, uuid: uuid_module.UUID, apikey_params: schemas.APIKeyRequestParams, - principal=Security(get_current_principal, scopes=["admin:apikeys"]), db=Depends(get_database_session), ): "Generate an API key for a Principal." @@ -1004,7 +834,6 @@ async def revoke_session( async def revoke_session_by_id( session_id: str, # from path parameter request: Request, - principal: schemas.Principal = Security(get_current_principal, scopes=[]), db=Depends(get_database_session), ): "Mark a Session as revoked so it cannot be refreshed again." @@ -1091,7 +920,6 @@ async def slide_session(refresh_token, settings, db): async def new_apikey( request: Request, apikey_params: schemas.APIKeyRequestParams, - principal=Security(get_current_principal, scopes=["apikeys"]), db=Depends(get_database_session), ): """ @@ -1144,7 +972,6 @@ async def current_apikey_info( async def revoke_apikey( request: Request, first_eight: str, - principal=Security(get_current_principal, scopes=["apikeys"]), db=Depends(get_database_session), ): """ @@ -1174,7 +1001,6 @@ async def revoke_apikey( ) async def whoami( request: Request, - principal=Security(get_current_principal, scopes=[]), db=Depends(get_database_session), ): # TODO Permit filtering the fields of the response. @@ -1210,7 +1036,6 @@ async def whoami( async def logout( request: Request, response: Response, - principal=Security(get_current_principal, scopes=[]), ): "Deprecated. See revoke_session: POST /session/revoke." request.state.endpoint = "auth" diff --git a/tiled/server/dependencies.py b/tiled/server/dependencies.py index fd2d4d4e8..4486b8126 100644 --- a/tiled/server/dependencies.py +++ b/tiled/server/dependencies.py @@ -13,7 +13,6 @@ ) from ..query_registration import query_registry as default_query_registry from ..validation_registration import validation_registry as default_validation_registry -from .authentication import get_current_principal, get_session_state from .core import NoEntry from .utils import filter_for_access, record_timing @@ -59,9 +58,7 @@ def SecureEntry(scopes, structure_families=None): async def inner( path: str, request: Request, - principal: str = Depends(get_current_principal), root_tree: pydantic_settings.BaseSettings = Depends(get_root_tree), - session_state: dict = Depends(get_session_state), ): """ Obtain a node in the tree from its path. @@ -85,13 +82,11 @@ async def inner( # If the entry/adapter can take a session state, pass it in. # The entry/adapter may return itself or a different object. - if hasattr(entry, "with_session_state") and session_state: - entry = entry.with_session_state(session_state) # start at the root # filter and keep only what we are allowed to see from here entry = await filter_for_access( entry, - principal, + "", ["read:metadata"], request.state.metrics, path_parts_relative, @@ -120,7 +115,7 @@ async def inner( # filter and keep only what we are allowed to see from here entry = await filter_for_access( entry, - principal, + "", ["read:metadata"], request.state.metrics, path_parts_relative, @@ -131,7 +126,7 @@ async def inner( if access_policy is not None: with record_timing(request.state.metrics, "acl"): allowed_scopes = await access_policy.allowed_scopes( - entry_with_access_policy, principal, path_parts_relative + entry_with_access_policy, "", path_parts_relative ) if not set(scopes).issubset(allowed_scopes): if "read:metadata" not in allowed_scopes: diff --git a/tiled/server/metrics.py b/tiled/server/metrics.py index 2139fe132..5b950062a 100644 --- a/tiled/server/metrics.py +++ b/tiled/server/metrics.py @@ -10,7 +10,6 @@ from fastapi import APIRouter, Request, Response, Security from prometheus_client import CONTENT_TYPE_LATEST, Histogram, generate_latest -from .authentication import get_current_principal router = APIRouter() @@ -158,7 +157,7 @@ def prometheus_registry(): @router.get("/metrics") async def metrics( - request: Request, principal=Security(get_current_principal, scopes=["metrics"]) + request: Request ): """ Prometheus metrics diff --git a/tiled/server/router.py b/tiled/server/router.py index 050baf62f..3e62cf2a2 100644 --- a/tiled/server/router.py +++ b/tiled/server/router.py @@ -31,7 +31,7 @@ from ..utils import ensure_awaitable, patch_mimetypes, path_from_uri from ..validation_registration import ValidationError from . import schemas -from .authentication import Mode, get_authenticators, get_current_principal +from .authentication import Mode, get_authenticators from .core import ( DEFAULT_PAGE_SIZE, DEPTH_LIMIT, @@ -76,7 +76,6 @@ async def about( query_registry=Depends(get_query_registry), # This dependency is here because it runs the code that moves # API key from the query parameter to a cookie (if it is valid). - principal=Security(get_current_principal, scopes=[]), ): # TODO The lazy import of entry modules and serializers means that the # lists of formats are not populated until they are first used. Not very @@ -170,7 +169,6 @@ async def search( include_data_sources: bool = Query(False), entry: Any = SecureEntry(scopes=["read:metadata"]), query_registry=Depends(get_query_registry), - principal: str = Depends(get_current_principal), **filters, ): request.state.endpoint = "search" @@ -745,7 +743,6 @@ async def get_container_full( entry=SecureEntry( scopes=["read:data"], structure_families={StructureFamily.container} ), - principal: str = Depends(get_current_principal), field: Optional[List[str]] = Query(None, min_length=1), format: Optional[str] = None, filename: Optional[str] = None, @@ -775,7 +772,6 @@ async def post_container_full( entry=SecureEntry( scopes=["read:data"], structure_families={StructureFamily.container} ), - principal: str = Depends(get_current_principal), field: Optional[List[str]] = Body(None, min_length=1), format: Optional[str] = None, filename: Optional[str] = None, @@ -852,7 +848,6 @@ async def node_full( scopes=["read:data"], structure_families={StructureFamily.table, StructureFamily.container}, ), - principal: str = Depends(get_current_principal), field: Optional[List[str]] = Query(None, min_length=1), format: Optional[str] = None, filename: Optional[str] = None,