Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
93 commits
Select commit Hold shift + click to select a range
0768314
Use harbor to avoid docker rate limiting
beenje Dec 22, 2021
4f22a5d
Add web ui basic structure
beenje Nov 24, 2021
48d2898
Get user from cookie
beenje Dec 10, 2021
c81eb40
Add web settings
beenje Dec 11, 2021
8b1e6a7
Display notifications
beenje Dec 12, 2021
ffb62e6
Add htmx
beenje Dec 27, 2021
e659b5e
Add form to select services to display
beenje Dec 28, 2021
3b4653e
Add SessionMiddleware
beenje Jan 2, 2022
a8ba963
Add notifications polling
beenje Jan 3, 2022
c64610b
Allow to filter notifications by services id
beenje Jan 3, 2022
d23ffad
Use SQL query to filter notifications
beenje Jan 4, 2022
b6e55c6
Add notifications limit
beenje Jan 5, 2022
5f7e42a
Use itsdangerous to sign cookie
beenje Jan 5, 2022
6ffca10
Replace tab with spaces
beenje Jan 10, 2022
029c2d4
Add aiofiles requirement
beenje Jan 10, 2022
10dbc69
Clean dependencies
beenje Jan 10, 2022
1ffb662
Merge branch 'webui' into 'master'
Mar 22, 2022
5e9329c
Merge branch 'ess-master'
beenje Sep 12, 2022
05e57b1
Merge remote-tracking branch 'origin/master'
beenje Oct 17, 2022
c245015
Merge branch 'ess-master'
beenje Nov 17, 2022
aff6eb1
Merge remote-tracking branch 'origin/master'
beenje Dec 5, 2023
31837a1
Update MAX IV gitlab-ci
beenje Dec 5, 2023
159d71f
Fix tests for pydantic >= 2.3.0
beenje Dec 12, 2023
ac0e755
Add docker image test to MAX IV pipeline
beenje Dec 12, 2023
d4d02aa
Update requirements
beenje Dec 13, 2023
57eea9e
Update helm chart repo and template pipeline
beenje Dec 13, 2023
b359cd1
Merge branch 'fix-new-pydantic' into 'master'
beenje Dec 13, 2023
644f828
Change branch to main
beenje Dec 13, 2023
4c2c9fe
Enable buildah cache
beenje Feb 6, 2024
609628f
Update requirements
beenje Feb 6, 2024
4c0ceb5
Fix FileNotFoundError when .env doesn't exist
beenje Feb 6, 2024
630dc9c
Update pre-commit
beenje Feb 6, 2024
9c55a5f
Merge branch 'fix-new-starlette' into 'main'
beenje Feb 6, 2024
f50e458
Use timezone aware datetime objects
beenje Feb 9, 2024
788aa92
Format datetime in local timezone in web app
beenje Feb 9, 2024
1cfe0c0
Merge branch 'timezone-datetime' into 'main'
beenje Feb 15, 2024
7f6d6d2
Drop support for Python 3.8
beenje Feb 15, 2024
6e00c8c
Fix send_notification background task
beenje Feb 26, 2024
fbd4035
Merge branch 'session-background-task' into 'main'
beenje Feb 27, 2024
f55de9b
Replace setup.py with pyproject.toml
beenje Oct 17, 2024
4b28b7d
Update requirements
beenje Oct 17, 2024
289a0d5
Replace flake8 and black with ruff
beenje Oct 17, 2024
028b572
Fix test for new pydantic
beenje Oct 17, 2024
5fc6488
Merge branch 'pyproject' into 'main'
beenje Oct 17, 2024
3c9530d
Add OpenID Connect authentication for web
beenje Oct 18, 2024
3971640
Catch exception on authorize_access_token
beenje Oct 23, 2024
2aca0f5
Add OpenID Connect Authentication Code Flow support
beenje Feb 18, 2025
5f88978
Vendor fastapi-versioning 0.10.0
beenje Feb 19, 2025
70cf1d6
Disable autodoc in fastapi-versioning
beenje Feb 19, 2025
6f6c188
Remove unused AUTH_COOKIE_NAME
beenje Feb 20, 2025
2d5a9e4
Add OIDC_ENABLED variable
beenje Feb 20, 2025
bc3a8d1
Improve security
beenje Feb 20, 2025
ce5c07d
Add custom swagger UI endpoint
beenje Feb 19, 2025
fe70ee6
Update requirements
beenje Feb 20, 2025
f481102
Use same client_id for mobile apps and backend
beenje Feb 20, 2025
876d8fc
Update ruff
beenje Feb 28, 2025
1f8ee0f
Upgrade requirements
beenje Mar 3, 2025
2b12ae4
Merge branch 'keycloak-new' into 'main'
beenje Mar 3, 2025
6ea92e1
Requires python 3.11
beenje Mar 3, 2025
169b9ee
Make BUNDLE_ID configurable
beenje Sep 29, 2025
0ae936d
Merge branch 'fix-bundle-id' into 'main'
beenje Sep 29, 2025
b4acb3d
Add realm discovery endpoint
CarlaCTaka Nov 7, 2025
6547c84
Update CI/CD (to trigger rebuild)
Nov 7, 2025
6d01ca7
test redirect_uri
CarlaCTaka Nov 13, 2025
bbaff0d
test redirect_uri and test ingreess host
CarlaCTaka Nov 13, 2025
3e9678f
Realm discovery by type
CarlaCTaka Nov 19, 2025
75f7885
More robust defaulting
CarlaCTaka Nov 19, 2025
56b683a
fix import
CarlaCTaka Nov 21, 2025
8c34d19
Edit .gitlab-ci.maxiv.yml
CarlaCTaka Nov 21, 2025
eb024e6
Fix CI
CarlaCTaka Nov 24, 2025
22eedb9
Update Readme [skip ci]
CarlaCTaka Nov 26, 2025
22feea2
Use well known OIDC discovery URI and oidc config per realm
CarlaCTaka Dec 9, 2025
00c00e4
Get the jwks_client for each realm and fastapi validation of realm di…
CarlaCTaka Dec 9, 2025
5b9474a
Update README
CarlaCTaka Dec 9, 2025
21b88cf
Issue on realm .well-known availability should not break the service …
CarlaCTaka Dec 9, 2025
b3271ff
make demo username configurable
CarlaCTaka Dec 10, 2025
3da40d4
simplify realm mapping
CarlaCTaka Dec 10, 2025
08d6b4d
Provide authorization and token enpoints and client id
CarlaCTaka Dec 10, 2025
b7625fa
Update Readme [skip ci]
CarlaCTaka Dec 11, 2025
9ee48ef
improve defaulting and client by realm type references
CarlaCTaka Dec 12, 2025
806d8d7
fix wrong key
CarlaCTaka Dec 12, 2025
5b88341
Use standard ingress host for test server
CarlaCTaka Dec 12, 2025
ff6d81d
Minor fixes
CarlaCTaka Dec 15, 2025
2251188
minor fixes
CarlaCTaka Dec 15, 2025
0360aaa
use DEMO_ACCOUNT_USERNAME in crud.py
CarlaCTaka Dec 15, 2025
6457d46
Send realm in payload when open id connecting
CarlaCTaka Dec 16, 2025
4765797
Merge branch 'add-realm-discovery' into 'main'
beenje Dec 17, 2025
5c01f6b
Update requirements and python
beenje Dec 17, 2025
ec02947
Update pre-commit config
beenje Dec 17, 2025
f3937c7
Merge branch 'update-dep' into 'main'
beenje Dec 17, 2025
f2cb59e
Remove harbor from Dockerfile
beenje Dec 19, 2025
4b0bcbf
Merge branch 'main' into add-demo-realm
beenje Dec 19, 2025
2635505
Use python 3.14 in default gitlab-ci and github actions
beenje Dec 19, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/pytest.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
13 changes: 6 additions & 7 deletions .gitlab-ci.maxiv.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -25,28 +25,27 @@ 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"
HELM_SET_TEST_image_tag: "__from_env_var:REGISTRY_IMAGE_TAG"
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:
Expand Down
4 changes: 2 additions & 2 deletions .gitlab-ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
6 changes: 3 additions & 3 deletions .pre-commit-config.yaml
Original file line number Diff line number Diff line change
@@ -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:
Expand Down
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
@@ -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)
Expand Down
77 changes: 73 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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=<real|demo> — discover which realm and
client to use for a given app "type".
- POST /api/v1/open_id_connect?realm=<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
Expand Down
53 changes: 46 additions & 7 deletions app/api/login.py
Original file line number Diff line number Diff line change
@@ -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()
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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:
Expand All @@ -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.")
Expand All @@ -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
3 changes: 2 additions & 1 deletion app/api/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand All @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions app/auth.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions app/crud.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down Expand Up @@ -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)
Expand Down
25 changes: 23 additions & 2 deletions app/deps.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,19 +9,40 @@
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()
oauth.register(
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},
)

Expand Down
Loading
Loading