diff --git a/.github/workflows/pytest.yml b/.github/workflows/pytest.yml index dec80ae..f1aa990 100644 --- a/.github/workflows/pytest.yml +++ b/.github/workflows/pytest.yml @@ -10,7 +10,7 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - python-version: ["3.11", "3.12"] + python-version: ["3.11", "3.14"] steps: - uses: actions/checkout@v2 diff --git a/.gitlab-ci.maxiv.yml b/.gitlab-ci.maxiv.yml index 313c095..c6fa0db 100644 --- a/.gitlab-ci.maxiv.yml +++ b/.gitlab-ci.maxiv.yml @@ -4,7 +4,7 @@ include: file: "/PreCommit.gitlab-ci.yml" - project: kits-maxiv/kubernetes/k8s-gitlab-ci file: "/Docker-Helm-deploy.gitlab-ci.yml" - ref: "0.6" + ref: "0.8" # Override workflow rules to deploy on any branch workflow: @@ -25,16 +25,15 @@ stages: - .post variables: - DOCKER_REGISTRY_URL: "harbor.maxiv.lu.se/notify-server" - HELM_CHART_REPO: oci://harbor.maxiv.lu.se/notify-server/charts + BUILDAH_CACHE: "true" HELM_CHART_NAME: notify-server HELM_RELEASE_NAMESPACE_TEST: notify HELM_RELEASE_NAMESPACE_PROD: notify PIPELINES_FF_TOKENIZER: "true" - PRODUCTION_BRANCH_NAME: "master" + PRODUCTION_BRANCH_NAME: "main" PRODUCTION_DEPLOY_ON_TAG: "true" HELM_SET_PROD_ingress_host: "notify.maxiv.lu.se" - HELM_SET_TEST_ingress_host: "notify-test-${CI_COMMIT_BRANCH}.apps.okdev.maxiv.lu.se" + HELM_SET_TEST_ingress_host: "notify-test.apps.okdev.maxiv.lu.se" # This URL needs to be registered on the OIDC provider as valid redirect URI HELM_SET_PROD_image_repository: "__from_env_var:REGISTRY_IMAGE_NAME" HELM_SET_PROD_image_tag: "__from_env_var:REGISTRY_IMAGE_TAG" HELM_SET_TEST_image_repository: "__from_env_var:REGISTRY_IMAGE_NAME" @@ -42,11 +41,11 @@ variables: GITLAB_ENVIRONMENT_NAME: notify # Test with latest versions of requirements -test-python311: +test-python314: stage: test tags: - kubernetes - image: harbor.maxiv.lu.se/dockerhub/library/python:3.11 + image: harbor.maxiv.lu.se/dockerhub/library/python:3.14 before_script: - pip install -e .[tests] script: diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index dd3c5a1..c46709f 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -5,9 +5,9 @@ include: - remote: 'https://gitlab.esss.lu.se/ics-infrastructure/gitlab-ci-yml/raw/master/Docker.gitlab-ci.yml' -test-python311: +test-python314: stage: test - image: python:3.11 + image: docker.io/library/python:3.14 before_script: - pip install -e .[tests] script: diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index e2e4161..1cbfc79 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,15 +1,15 @@ repos: - repo: https://github.com/astral-sh/ruff-pre-commit # Ruff version. - rev: v0.9.9 + rev: v0.14.9 hooks: # Run the linter. - - id: ruff + - id: ruff-check # Run the formatter. - id: ruff-format - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.15.0 + rev: v1.19.1 hooks: - id: mypy additional_dependencies: diff --git a/Dockerfile b/Dockerfile index 67466ab..61dc84a 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM python:3.11-slim as base +FROM docker.io/library/python:3.14-slim as base # Install Python dependencies in an intermediate image # as some requires a compiler (psycopg2) diff --git a/README.md b/README.md index c5b17d7..87c1cb9 100644 --- a/README.md +++ b/README.md @@ -37,21 +37,90 @@ To be able to login, at least the following variables shall be overwritten: - LDAP_HOST - LDAP_USER_DN -Refer to the default values defined in the [Ansible role](https://gitlab.esss.lu.se/ics-ansible-galaxy/ics-ans-role-ess-notify-server/-/blob/master/defaults/main.yml) -and in the [ess_notify_servers](https://csentry.esss.lu.se/network/groups/view/ess_notify_servers) group in CSEntry. +### OpenID Connect with Automatic Realm Discovery for Mobile Apps + +The implementation provides two OIDC-related endpoints used by mobile clients: + +- GET /api/v1/realm-discovery/?type= — discover which realm and + client to use for a given app "type". +- POST /api/v1/open_id_connect?realm= — exchange an OIDC authorization + code (server performs token/userinfo calls, validates id_token and issues a + local access token). + +Exact environment variables used by the implementation + +- `OIDC_ENABLED` (bool) — enable OIDC features +- `OIDC_BASE_URL` (string) — base Keycloak URL (e.g. https://keycloak.example.org/auth) +- `OIDC_DEFAULT_REALM` (string) — default realm used +- `OIDC_CLIENT_ID`, `OIDC_CLIENT_SECRET` — default client credentials +- `OIDC_SCOPE` — scope used when requesting userinfo +- `OIDC_DEMO_REALM`, `OIDC_DEMO_CLIENT_ID`, `OIDC_DEMO_CLIENT_SECRET` — demo realm/client values + +Realm discovery (mobile client) + +Request +```http +GET /api/v1/realm-discovery/?type=real +``` + +Response (implemented schema) +```json +{ + "realm": "company-realm", + "authorization_endpoint": "https://keycloak.example.org/auth/realms/company-realm/protocol/openid-connect/auth", + "token_endpoint": "https://keycloak.example.org/auth/realms/company-realm/protocol/openid-connect/token", + "client_id": "notify", + "type": "real" +} +``` + +Notes +- The endpoint expects the query parameter `type` (alias for the internal + `RealmType` enum). The implementation maps types to realms using + `deps.REALM_BY_TYPE` and exposes client IDs from `deps.CLIENT_BY_REALM`. +- The discovery response provides `authorization_endpoint` and `token_endpoint` + (not a single discovery URI), and the `client_id` the mobile app should + include in the initial authorization request. + +OpenID Connect token exchange (mobile -> server) + +After the mobile app completes the OIDC authorization code flow (using the +`client_id` provided and PKCE), the app should POST the authorization code to +the server which will perform the token/userinfo exchange and create a local +access token for the app. + +Request +```http +POST /api/v1/open_id_connect?realm=company-realm +Content-Type: application/json + +{ + "code": "authorization_code", + "code_verifier": "pkce_verifier", + "client_id": "notify", + "redirect_uri": "app://callback" +} +``` + +Behavior +- The server posts the code to the realm's token endpoint and validates the + returned id_token (using the realm's JWKS). +- Client secrets for token exchanges are looked up server-side from + `deps.CLIENT_BY_REALM_TYPE`; mobile apps MUST NOT embed client secrets. + ## Development ### Virtual environment -Python >= 3.6 is required. +Python >= 3.11 is required. Create a virtual environment and install the requirements: ```bash python3 -m venv .venv source .venv/bin/activate pip install -r requirements.txt -pip install -e .[tests] +pip install -e ".[tests]" ``` When using sqlite, it's not possible to run `alembic` for database migration diff --git a/app/api/login.py b/app/api/login.py index 081cca7..ac8adde 100644 --- a/app/api/login.py +++ b/app/api/login.py @@ -1,14 +1,14 @@ import httpx from datetime import datetime, timedelta, timezone -from fastapi import APIRouter, Depends, HTTPException, Response, Request, status +from fastapi import APIRouter, Depends, HTTPException, Response, Request, status, Query from fastapi.security import OAuth2PasswordRequestForm from fastapi.logger import logger from sqlalchemy.orm import Session from .. import deps, crud, utils, auth, schemas from ..settings import ( ACCESS_TOKEN_EXPIRE_MINUTES, - OIDC_CLIENT_SECRET, OIDC_SCOPE, + OIDC_ENABLED, ) router = APIRouter() @@ -51,10 +51,12 @@ async def open_id_connect( db: Session = Depends(deps.get_db), ): """Login using OpenID Connect Authentication Code flow from mobile client""" - oidc_config = request.state.oidc_config + realm = oidc_auth.realm + oidc_config = request.state.oidc_config[realm] + jwks_client = request.state.jwks_client[realm] data = { "client_id": oidc_auth.client_id, - "client_secret": OIDC_CLIENT_SECRET, + "client_secret": deps.CLIENT_BY_REALM_TYPE[realm]["client_secret"], "code": oidc_auth.code, "code_verifier": oidc_auth.code_verifier, "grant_type": "authorization_code", @@ -92,8 +94,8 @@ async def open_id_connect( utils.validate_id_token( id_token, access_token, - request.state.jwks_client, - request.state.oidc_config["id_token_signing_alg_values_supported"], + jwks_client, + oidc_config["id_token_signing_alg_values_supported"], oidc_auth.client_id, ) except Exception as e: @@ -105,7 +107,7 @@ async def open_id_connect( headers = {"Authorization": f"Bearer {access_token}"} data = { "client_id": oidc_auth.client_id, - "client_secret": OIDC_CLIENT_SECRET, + "client_secret": deps.CLIENT_BY_REALM_TYPE[realm]["client_secret"], "scope": OIDC_SCOPE, } logger.info("Retrieving user info.") @@ -131,3 +133,40 @@ async def open_id_connect( ) username = response.json()["preferred_username"].lower() return create_access_token(db, username, response) + + +@router.get( + "/realm-discovery/", + status_code=status.HTTP_200_OK, + response_model=schemas.RealmDiscoveryResponse, +) +def get_realm( + request: Request, + realm_type: schemas.RealmType = Query(..., alias="type"), +) -> schemas.RealmDiscoveryResponse: + """ + Discover the appropriate Keycloak realm for a realm type. + + Mobile apps should call this endpoint first to determine which realm to authenticate against. + The response includes all necessary OIDC endpoints and configuration for the discovered realm. + + """ + if not OIDC_ENABLED: + raise HTTPException( + status_code=status.HTTP_405_METHOD_NOT_ALLOWED, + detail="OIDC is not enabled", + ) + + # Discover realm + realm = deps.REALM_BY_TYPE[realm_type] + oidc_config = request.state.oidc_config[realm_type] + + response = schemas.RealmDiscoveryResponse( + realm=realm, + authorization_endpoint=oidc_config["authorization_endpoint"], + token_endpoint=oidc_config["token_endpoint"], + client_id=deps.CLIENT_BY_REALM_TYPE[realm_type]["client_id"], + type=realm_type, + ) + + return response diff --git a/app/api/services.py b/app/api/services.py index f85fec4..ee2e1c5 100644 --- a/app/api/services.py +++ b/app/api/services.py @@ -12,6 +12,7 @@ from sqlalchemy.orm import Session from typing import List, Optional from .. import deps, crud, models, schemas, utils +from ..settings import DEMO_ACCOUNT_USERNAME router = APIRouter() @@ -22,7 +23,7 @@ def read_services( current_user: models.User = Depends(deps.get_current_user), ): """Read all services""" - if current_user.username == "demo": + if current_user.username == DEMO_ACCOUNT_USERNAME: db_services = crud.get_services(db, demo=True) else: db_services = crud.get_services(db) diff --git a/app/auth.py b/app/auth.py index 8363812..ba7ee95 100644 --- a/app/auth.py +++ b/app/auth.py @@ -12,12 +12,12 @@ LDAP_BASE_DN, LDAP_USER_DN, ) -from .settings import DEMO_ACCOUNT_PASSWORD +from .settings import DEMO_ACCOUNT_PASSWORD, DEMO_ACCOUNT_USERNAME def authenticate_user(username: str, password: str) -> bool: """Return True if the authentication is successful, False otherwise""" - if username == "demo" and password == str(DEMO_ACCOUNT_PASSWORD): + if username == DEMO_ACCOUNT_USERNAME and password == str(DEMO_ACCOUNT_PASSWORD): return True if AUTHENTICATION_METHOD == "ldap": return ldap_authenticate_user(username, password) diff --git a/app/crud.py b/app/crud.py index febd5ee..b533f8f 100644 --- a/app/crud.py +++ b/app/crud.py @@ -5,7 +5,7 @@ from sqlalchemy.orm import Session from typing import List, Optional from . import models, schemas -from .settings import ADMIN_USERS, DEMO_ACCOUNT_SERVICE +from .settings import ADMIN_USERS, DEMO_ACCOUNT_SERVICE, DEMO_ACCOUNT_USERNAME def get_users(db: Session): @@ -136,7 +136,7 @@ def delete_service(db: Session, service: models.Service) -> None: def get_user_services(db: Session, user: models.User) -> List[schemas.UserService]: """Return all services for the user sorted by category""" - if user.username == "demo": + if user.username == DEMO_ACCOUNT_USERNAME: services = get_services(db, demo=True) else: services = get_services(db) diff --git a/app/deps.py b/app/deps.py index bf3a4d4..2adc899 100644 --- a/app/deps.py +++ b/app/deps.py @@ -9,11 +9,32 @@ from .database import SessionLocal from .settings import ( OIDC_NAME, - OIDC_SERVER_URL, + OIDC_BASE_URL, OIDC_CLIENT_ID, OIDC_CLIENT_SECRET, OIDC_SCOPE, + OIDC_DEFAULT_REALM, + OIDC_DEMO_REALM, + OIDC_DEMO_CLIENT_ID, + OIDC_DEMO_CLIENT_SECRET, ) +from . import schemas + + +REALM_BY_TYPE = { + schemas.RealmType.demo: OIDC_DEMO_REALM, + schemas.RealmType.real: OIDC_DEFAULT_REALM, +} +CLIENT_BY_REALM_TYPE = { + schemas.RealmType.demo: { + "client_id": OIDC_DEMO_CLIENT_ID, + "client_secret": OIDC_DEMO_CLIENT_SECRET, + }, + schemas.RealmType.real: { + "client_id": OIDC_CLIENT_ID, + "client_secret": OIDC_CLIENT_SECRET, + }, +} oauth2_scheme = OAuth2PasswordBearer(tokenUrl="login") oauth = OAuth() @@ -21,7 +42,7 @@ OIDC_NAME, client_id=OIDC_CLIENT_ID, client_secret=str(OIDC_CLIENT_SECRET), - server_metadata_url=OIDC_SERVER_URL, + server_metadata_url=f"{OIDC_BASE_URL}/realms/{OIDC_DEFAULT_REALM}/.well-known/openid-configuration", client_kwargs={"scope": OIDC_SCOPE}, ) diff --git a/app/main.py b/app/main.py index 9d1cc24..c908160 100644 --- a/app/main.py +++ b/app/main.py @@ -12,7 +12,7 @@ from fastapi.staticfiles import StaticFiles from starlette.middleware import Middleware from starlette.middleware.sessions import SessionMiddleware -from . import monitoring +from . import monitoring, schemas, deps from .api import login, users, services from .views import exceptions, account, notifications, settings, docs from .settings import ( @@ -20,8 +20,9 @@ ESS_NOTIFY_SERVER_ENVIRONMENT, SECRET_KEY, SESSION_MAX_AGE, - OIDC_SERVER_URL, + OIDC_BASE_URL, OIDC_ENABLED, + OIDC_DEFAULT_REALM, ) @@ -34,20 +35,24 @@ class State(TypedDict): - oidc_config: dict[str, str] - jwks_client: jwt.PyJWKClient | None + oidc_config: dict[schemas.RealmType, dict[str, str]] + jwks_client: dict[schemas.RealmType, jwt.PyJWKClient] @contextlib.asynccontextmanager async def lifespan(app: FastAPI) -> AsyncIterator[State]: + oidc_config = {} + jwks_client = {} if OIDC_ENABLED: async with httpx.AsyncClient() as client: - r = await client.get(OIDC_SERVER_URL) - oidc_config = r.json() - jwks_client = jwt.PyJWKClient(oidc_config["jwks_uri"]) - else: - oidc_config = {} - jwks_client = None + for realm_type in schemas.RealmType: + realm = deps.REALM_BY_TYPE.get(realm_type, OIDC_DEFAULT_REALM) + url = f"{OIDC_BASE_URL}/realms/{realm}/.well-known/openid-configuration" + r = await client.get(url) + if r.status_code == 200: + oidc_config[realm_type] = r.json() + jwks_uri = oidc_config[realm_type]["jwks_uri"] + jwks_client[realm_type] = jwt.PyJWKClient(jwks_uri) yield {"oidc_config": oidc_config, "jwks_client": jwks_client} diff --git a/app/schemas.py b/app/schemas.py index a7effca..40bd818 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -171,3 +171,17 @@ class OpenIdConnectAuth(BaseModel): code_verifier: str client_id: str redirect_uri: str + realm: RealmType + + +class RealmType(str, Enum): + real = "real" + demo = "demo" + + +class RealmDiscoveryResponse(BaseModel): + realm: str + authorization_endpoint: str + token_endpoint: str + client_id: str + type: RealmType diff --git a/app/settings.py b/app/settings.py index c210de2..619d1ab 100644 --- a/app/settings.py +++ b/app/settings.py @@ -31,15 +31,20 @@ # - API login (old authentication method still supported as well) OIDC_ENABLED = config("OIDC_ENABLED", cast=bool, default=False) OIDC_NAME = config("OIDC_NAME", cast=str, default="keycloak") -OIDC_SERVER_URL = config( - "OIDC_SERVER_URL", +OIDC_BASE_URL = config( + "OIDC_BASE_URL", cast=str, - default="https://keycloak.example.org/auth/realms/myrealm/.well-known/openid-configuration", + default="https://keycloak.example.org/auth", ) +OIDC_DEFAULT_REALM = config("OIDC_DEFAULT_REALM", cast=str, default="maxiv") OIDC_CLIENT_ID = config("OIDC_CLIENT_ID", cast=str, default="notify") OIDC_CLIENT_SECRET = config("OIDC_CLIENT_SECRET", cast=Secret, default="!secret") OIDC_SCOPE = config("OIDC_SCOPE", cast=str, default="openid email profile") - +OIDC_DEMO_REALM = config("OIDC_DEMO_REALM", cast=str, default="demo") +OIDC_DEMO_CLIENT_ID = config("OIDC_DEMO_CLIENT_ID", cast=str, default="notify") +OIDC_DEMO_CLIENT_SECRET = config( + "OIDC_DEMO_CLIENT_SECRET", cast=Secret, default="!secret" +) # URL to use when AUTHENTICATION_METHOD is set to "url" AUTHENTICATION_URL = config( "AUTHENTICATION_URL", cast=str, default="https//auth.example.org/login" @@ -47,6 +52,7 @@ ADMIN_USERS = config("ADMIN_USERS", cast=CommaSeparatedStrings, default="") # Demo account with "demo" username has access only to service defined in DEMO_ACCOUNT_SERVICE DEMO_ACCOUNT_SERVICE = config("DEMO_ACCOUNT_SERVICE", cast=str, default="demo") +DEMO_ACCOUNT_USERNAME = config("DEMO_ACCOUNT_USERNAME", cast=str, default="demo") DEMO_ACCOUNT_PASSWORD = config("DEMO_ACCOUNT_PASSWORD", cast=Secret, default="demo") SQLALCHEMY_DATABASE_URL = config( "SQLALCHEMY_DATABASE_URL", cast=str, default="sqlite:///./sql_app.db" @@ -61,7 +67,7 @@ APPLE_SERVER = config( "APPLE_SERVER", cast=str, default="api.development.push.apple.com" ) -BUNDLE_ID = "eu.ess.ESS-Notify" +BUNDLE_ID = config("BUNDLE_ID", cast=str, default="eu.ess.ESS-Notify") ALLOWED_NETWORKS = config("ALLOWED_NETWORKS", cast=CommaSeparatedStrings, default="") # Firebase settings diff --git a/app/templates/base.html b/app/templates/base.html index bfdb3a4..3a2f5b0 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -43,4 +43,4 @@
{% block main %}{% endblock %}
-{% endblock %} \ No newline at end of file +{% endblock %} diff --git a/requirements.txt b/requirements.txt index 1bf9eb9..56696e9 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,145 +1,149 @@ # This file was autogenerated by uv via the following command: # uv pip compile pyproject.toml -o requirements.txt -aiofiles==24.1.0 +aiofiles==25.1.0 # via ess-notify (pyproject.toml) alembic==1.14.1 # via ess-notify (pyproject.toml) +annotated-doc==0.0.4 + # via fastapi annotated-types==0.7.0 # via pydantic -anyio==4.8.0 +anyio==4.12.0 # via # httpx # starlette # watchfiles -authlib==1.5.1 +authlib==1.6.6 # via ess-notify (pyproject.toml) -cachetools==5.5.2 +cachetools==6.2.4 # via google-auth -certifi==2025.1.31 +certifi==2025.11.12 # via # httpcore # httpx # requests # sentry-sdk -cffi==1.17.1 +cffi==2.0.0 # via cryptography -charset-normalizer==3.4.1 +charset-normalizer==3.4.4 # via requests -click==8.1.8 +click==8.3.1 # via # typer # uvicorn -cryptography==44.0.2 +cryptography==46.0.3 # via # ess-notify (pyproject.toml) # authlib -fastapi==0.115.11 +fastapi==0.124.4 # via ess-notify (pyproject.toml) -google-auth==2.38.0 +google-auth==2.45.0 # via ess-notify (pyproject.toml) gunicorn==23.0.0 # via ess-notify (pyproject.toml) -h11==0.14.0 +h11==0.16.0 # via # httpcore # uvicorn -h2==4.2.0 +h2==4.3.0 # via ess-notify (pyproject.toml) hpack==4.1.0 # via h2 -httpcore==1.0.7 +httpcore==1.0.9 # via httpx -httptools==0.6.4 +httptools==0.7.1 # via uvicorn httpx==0.28.1 # via ess-notify (pyproject.toml) hyperframe==6.1.0 # via h2 -idna==3.10 +idna==3.11 # via # anyio # httpx # requests itsdangerous==2.2.0 # via ess-notify (pyproject.toml) -jinja2==3.1.5 +jinja2==3.1.6 # via ess-notify (pyproject.toml) ldap3==2.9.1 # via ess-notify (pyproject.toml) -mako==1.3.9 +mako==1.3.10 # via alembic -markdown-it-py==3.0.0 +markdown-it-py==4.0.0 # via rich -markupsafe==3.0.2 +markupsafe==3.0.3 # via # jinja2 # mako mdurl==0.1.2 # via markdown-it-py -packaging==24.2 +packaging==25.0 # via gunicorn pyasn1==0.6.1 # via # ldap3 # pyasn1-modules # rsa -pyasn1-modules==0.4.1 +pyasn1-modules==0.4.2 # via google-auth -pycparser==2.22 +pycparser==2.23 # via cffi -pydantic==2.10.6 +pydantic==2.12.5 # via # ess-notify (pyproject.toml) # fastapi -pydantic-core==2.27.2 +pydantic-core==2.41.5 # via pydantic -pygments==2.19.1 +pygments==2.19.2 # via rich pyjwt==2.10.1 # via ess-notify (pyproject.toml) -python-dotenv==1.0.1 +python-dotenv==1.2.1 # via uvicorn -python-multipart==0.0.20 +python-multipart==0.0.21 # via ess-notify (pyproject.toml) -pyyaml==6.0.2 +pyyaml==6.0.3 # via uvicorn -requests==2.32.3 +requests==2.32.5 # via ess-notify (pyproject.toml) -rich==13.9.4 +rich==14.2.0 # via typer -rsa==4.9 +rsa==4.9.1 # via google-auth -sentry-sdk==2.22.0 +sentry-sdk==2.48.0 # via ess-notify (pyproject.toml) shellingham==1.5.4 # via typer -sniffio==1.3.1 - # via anyio sqlalchemy==1.3.24 # via # ess-notify (pyproject.toml) # alembic -starlette==0.46.0 +starlette==0.50.0 # via fastapi -typer==0.15.2 +typer==0.20.0 # via ess-notify (pyproject.toml) -typing-extensions==4.12.2 +typing-extensions==4.15.0 # via # alembic # anyio # fastapi # pydantic # pydantic-core + # starlette # typer -urllib3==2.3.0 + # typing-inspection +typing-inspection==0.4.2 + # via pydantic +urllib3==2.6.2 # via # requests # sentry-sdk -uvicorn==0.34.0 +uvicorn==0.38.0 # via ess-notify (pyproject.toml) -uvloop==0.21.0 +uvloop==0.22.1 # via uvicorn -watchfiles==1.0.4 +watchfiles==1.1.1 # via uvicorn -websockets==15.0 +websockets==15.0.1 # via uvicorn diff --git a/tests/api/test_login_realm_discovery.py b/tests/api/test_login_realm_discovery.py new file mode 100644 index 0000000..6abb63e --- /dev/null +++ b/tests/api/test_login_realm_discovery.py @@ -0,0 +1,268 @@ +""" +Tests for mobile API authentication with realm discovery. +""" + +import pytest +from unittest.mock import patch +from fastapi.testclient import TestClient +from app.main import app +from app.schemas import RealmType + + +# Test-only middleware: inject request.state.oidc_config and jwks_client so +# handlers that expect those attributes can run without needing the full +# application lifespan to execute. +from starlette.middleware.base import BaseHTTPMiddleware + + +class _InjectStateMiddleware(BaseHTTPMiddleware): + def __init__(self, app): + super().__init__(app) + + async def dispatch(self, request, call_next): + # Build a small, predictable oidc_config mapping based on the current + # deps.REALM_BY_TYPE. Tests mutate that mapping, so build it at request + # time to reflect test changes. + from app import deps + from app.settings import OIDC_BASE_URL + + oidc = {} + jwks = {} + # deps.REALM_BY_TYPE maps RealmType -> realm string. Build entries for + # both the RealmType key and the realm string so handlers can look up + # by either. + for realm_type, realm in deps.REALM_BY_TYPE.items(): + cfg = { + "authorization_endpoint": f"{OIDC_BASE_URL}/realms/{realm}/protocol/openid-connect/auth", + "token_endpoint": f"{OIDC_BASE_URL}/realms/{realm}/protocol/openid-connect/token", + "userinfo_endpoint": f"{OIDC_BASE_URL}/realms/{realm}/protocol/openid-connect/userinfo", + "id_token_signing_alg_values_supported": ["RS256"], + } + oidc[realm_type] = cfg + jwks[realm_type] = None + + request.state.oidc_config = oidc + request.state.jwks_client = jwks + return await call_next(request) + + +@pytest.fixture(scope="module", autouse=True) +def _inject_state_middleware(): + """Add middleware to the app used by these tests. Kept module-scoped so + middleware is added once for the test module.""" + # Import here to avoid circular imports at module import time + from app.main import app as _original_app + from fastapi import FastAPI + + # Create a fresh wrapper app and add the middleware to it before the + # application is started. Mount the original app under the wrapper so the + # wrapper's middleware runs first and injects the request.state values. + wrapper = FastAPI(docs_url=None, redoc_url=None) + wrapper.add_middleware(_InjectStateMiddleware) + wrapper.mount("/", _original_app) + + # Replace the module-level `app` so tests that do `TestClient(app)` get + # the wrapped app with our middleware. + globals()["app"] = wrapper + yield + + +@pytest.fixture(autouse=True) +def reset_realm_discovery_state(): + """Reset realm discovery module state between tests.""" + import app.api.login as login_api + import app.deps as deps # Store original values + + original_oidc_enabled = login_api.OIDC_ENABLED + original_default = deps.OIDC_DEFAULT_REALM + original_base_url = deps.OIDC_BASE_URL + original_demo_realm = deps.OIDC_DEMO_REALM + # store mappings built at import time so tests can mutate them safely + original_realm_by_type = dict(deps.REALM_BY_TYPE) + original_client_by_realm_type = dict(deps.CLIENT_BY_REALM_TYPE) + + yield # Run the test + + # Restore original values after test + login_api.OIDC_ENABLED = original_oidc_enabled + deps.OIDC_DEFAULT_REALM = original_default + deps.OIDC_BASE_URL = original_base_url + deps.OIDC_DEMO_REALM = original_demo_realm + deps.OIDC_DEFAULT_REALM = original_default + deps.REALM_BY_TYPE = original_realm_by_type + deps.CLIENT_BY_REALM_TYPE = original_client_by_realm_type + + +@patch("app.api.login.OIDC_ENABLED", False) +def test_realm_discovery_endpoint_oidc_disabled(): + """Test realm discovery endpoint when OIDC is disabled.""" + import app.api.login as login_api + + # Set up test configuration directly on modules + login_api.OIDC_ENABLED = False + + client = TestClient(app) + response = client.get("/api/v1/realm-discovery/?type=real") + + assert response.status_code == 405 + assert response.json()["detail"] == "OIDC is not enabled" + + +@patch("app.api.login.OIDC_ENABLED", True) +def test_invalid_realm_type(): + """Test realm discovery endpoint when realm type is invalid.""" + client = TestClient(app) + response = client.get("/api/v1/realm-discovery/?type=test") + + assert response.status_code == 422 + assert response.json()["detail"][0]["ctx"] == {"expected": "'real' or 'demo'"} + + +@pytest.mark.parametrize( + "realm_type,demo_realm,expected_realm", + [ + (RealmType.demo, "demo-realm", "demo-realm"), + (RealmType.real, "demo", "default"), + (RealmType.demo, "", "default"), + ], +) +def test_realm_discovery_endpoint_success(realm_type, demo_realm, expected_realm): + """Test realm discovery with various pattern matching (domain, prefix, exact).""" + import app.api.login as login_api + import app.deps as deps + from app.settings import OIDC_BASE_URL + + # Set up test configuration directly on modules + login_api.OIDC_ENABLED = True + login_api.OIDC_DEFAULT_REALM = "default" + deps.OIDC_DEFAULT_REALM = "default" + deps.OIDC_DEMO_REALM = demo_realm + # make realm mapping deterministic for the test + deps.REALM_BY_TYPE = { + RealmType.demo: demo_realm if demo_realm else deps.OIDC_DEFAULT_REALM, + RealmType.real: login_api.OIDC_DEFAULT_REALM, + } + # set predictable client ids for assertion + deps.CLIENT_BY_REALM_TYPE = { + RealmType.demo: {"client_id": "demo-client"}, + RealmType.real: {"client_id": "real-client"}, + } + expected_client_id = deps.CLIENT_BY_REALM_TYPE[realm_type]["client_id"] + client = TestClient(app) + response = client.get(f"/api/v1/realm-discovery/?type={realm_type.value}") + + assert response.status_code == 200 + data = response.json() + assert data["type"] == realm_type.value + assert data["realm"] == expected_realm + # Response must expose the OIDC endpoints for the discovered realm + assert ( + data["authorization_endpoint"] + == f"{OIDC_BASE_URL}/realms/{expected_realm}/protocol/openid-connect/auth" + ) + assert ( + data["token_endpoint"] + == f"{OIDC_BASE_URL}/realms/{expected_realm}/protocol/openid-connect/token" + ) + assert data["client_id"] == expected_client_id + + +@pytest.mark.parametrize( + "realm_type,expected_realm", + [(RealmType.demo, "demo-realm"), (RealmType.real, "admin-realm")], +) +@patch("app.api.login.OIDC_ENABLED", True) +@patch("app.deps.OIDC_DEMO_REALM", "demo") +@patch("app.api.login.OIDC_DEFAULT_REALM", "default") +def test_realm_discovery_multiple_mappings(realm_type, expected_realm): + """Test realm discovery with multiple mapping rules.""" + import app.deps as deps + + # create explicit mapping for this test + deps.REALM_BY_TYPE = {RealmType.demo: "demo-realm", RealmType.real: "admin-realm"} + deps.CLIENT_BY_REALM_TYPE = { + RealmType.demo: {"client_id": "demo-client"}, + RealmType.real: {"client_id": "real-client"}, + } + client = TestClient(app) + response = client.get(f"/api/v1/realm-discovery/?type={realm_type.value}") + + assert response.status_code == 200 + data = response.json() + assert data["realm"] == expected_realm + + +@patch("app.api.login.OIDC_ENABLED", True) +@patch("app.deps.OIDC_DEMO_REALM", "") +@patch("app.api.login.OIDC_DEFAULT_REALM", "master") +def test_realm_discovery_response_structure(): + """Test that response contains all required fields with correct types.""" + + client = TestClient(app) + response = client.get("/api/v1/realm-discovery/?type=demo") + + assert response.status_code == 200 + data = response.json() + + # Check required fields exist + required_fields = [ + "realm", + "authorization_endpoint", + "token_endpoint", + "client_id", + "type", + ] + for field in required_fields: + assert field in data, f"Missing required field: {field}" + + # Check field types + string_fields = [ + "realm", + "authorization_endpoint", + "token_endpoint", + "client_id", + "type", + ] + for field in string_fields: + assert isinstance(data[field], str), f"Field {field} should be string" + + # Check URLs are properly formatted + url_fields = ["authorization_endpoint", "token_endpoint"] + for field in url_fields: + assert data[field].startswith("https://"), ( + f"Field {field} should start with https://" + ) + + # Check realm type is valid + assert data["type"] in [e.value for e in RealmType], "Invalid realm type" + + +@pytest.mark.parametrize( + "field_name,field_value", + [ + ("realm", "master"), + ("type", "real"), + ], +) +@patch("app.api.login.OIDC_ENABLED", True) +@patch("app.deps.OIDC_DEMO_REALM", "") +def test_realm_discovery_response_field_values(field_name, field_value): + """Test that response fields contain expected values.""" + import app.deps as deps + import app.api.login as login_api + + # Ensure mapping returns master for real + deps.REALM_BY_TYPE = { + RealmType.real: "master", + RealmType.demo: login_api.OIDC_DEFAULT_REALM, + } + deps.CLIENT_BY_REALM_TYPE = { + RealmType.demo: {"client_id": "demo-client"}, + RealmType.real: {"client_id": "real-client"}, + } + client = TestClient(app) + response = client.get("/api/v1/realm-discovery/?type=real") + + assert response.status_code == 200 + data = response.json() + assert data[field_name] == field_value