From 5fe049dd85b2b997c1ab8fc06fdddb0cc2d9f00d Mon Sep 17 00:00:00 2001 From: JonaBenja Date: Wed, 11 Nov 2026 15:44:23 +0100 Subject: [PATCH 1/3] Accept entra token structure and make auth more strict --- compose.yml | 15 ++++++++------- jwtproxy/src/server.py | 11 +++++------ 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/compose.yml b/compose.yml index 061ee124..60f50622 100644 --- a/compose.yml +++ b/compose.yml @@ -11,7 +11,7 @@ services: - POSTGRES_PASSWORD=insecure - POSTGRES_DB=postgres - command: ["postgres", "-c", "log_statement=all"] + command: [ "postgres", "-c", "log_statement=all" ] volumes: - "${POSTGRES_DATA_DIR:-/var/lib/postgresql/data}:/var/lib/postgresql/data" @@ -22,12 +22,13 @@ services: ports: - 9191:8000 environment: - PROXY_URL: http://map - JWKS_PATH: /app/test_jwk.json + PRIVATE_PROXY_URL: http://private:8080 + PUBLIC_PROXY_URL: http://public:8080 + #JWKS_PATH: /app/test_jwk.json LOG_LEVEL: DEBUG #JWKS_URL: "https://iam.amsterdam.nl/auth/realms/datapunt-ad-acc/protocol/openid-connect/certs" - - # Bare bones 'webserver' to behave as public loadbalancer + JWKS_URL: "https://login.microsoftonline.com/72fca1b1-2c2e-4376-a445-294d80196804/discovery/keys" + # Bare bones 'webserver' to behave as public loadbalancer publicdummy: image: python:3.13-slim-bullseye ports: @@ -63,7 +64,7 @@ services: args: BASE_IMAGE: mapserver/public depends_on: - - public + - public ports: - "8384:8080" environment: @@ -90,7 +91,7 @@ services: args: BASE_IMAGE: mapserver/public depends_on: - - public + - public ports: - "8385:8080" environment: diff --git a/jwtproxy/src/server.py b/jwtproxy/src/server.py index b0543e3e..d274e62d 100755 --- a/jwtproxy/src/server.py +++ b/jwtproxy/src/server.py @@ -121,8 +121,7 @@ async def fetch_token(req: web.Request): async def audit(req: web.Request, token: str): - # Note that this function only supports token formats issued by - # the iam KeyCloak server. + # This function now supports tokens issued by both Keycloak and Entra ID j = JWT() jwks = await get_jwk() @@ -137,10 +136,10 @@ async def audit(req: web.Request, token: str): raise web.HTTPUnauthorized(reason="Token expired") from e claims = json.loads(j.claims) - try: - if "fp_mdw" not in claims["realm_access"]["roles"]: - raise ValueError("Required scope not present in claims") - except (KeyError, ValueError): + + has_access = "fp_mdw" in claims.get("realm_access", {}).get("roles", []) or "FP/MDW" in claims.get("roles", []) + + if not has_access: audit_logger.info( "Access to %s (%s) denied for subject %s (username: %s)", req.url, From 21735e41081e0c4c278df856a454410f4892e479 Mon Sep 17 00:00:00 2001 From: JonaBenja Date: Wed, 11 Nov 2026 16:49:55 +0100 Subject: [PATCH 2/3] Support multiple access tokens from 2 auth services --- compose.yml | 4 +- jwtproxy/src/server.py | 94 +++++++++++++++++++++++++++--------------- 2 files changed, 62 insertions(+), 36 deletions(-) diff --git a/compose.yml b/compose.yml index 60f50622..902919be 100644 --- a/compose.yml +++ b/compose.yml @@ -24,10 +24,8 @@ services: environment: PRIVATE_PROXY_URL: http://private:8080 PUBLIC_PROXY_URL: http://public:8080 - #JWKS_PATH: /app/test_jwk.json + JWKS_PATH: /app/test_jwk.json LOG_LEVEL: DEBUG - #JWKS_URL: "https://iam.amsterdam.nl/auth/realms/datapunt-ad-acc/protocol/openid-connect/certs" - JWKS_URL: "https://login.microsoftonline.com/72fca1b1-2c2e-4376-a445-294d80196804/discovery/keys" # Bare bones 'webserver' to behave as public loadbalancer publicdummy: image: python:3.13-slim-bullseye diff --git a/jwtproxy/src/server.py b/jwtproxy/src/server.py index d274e62d..136847a9 100755 --- a/jwtproxy/src/server.py +++ b/jwtproxy/src/server.py @@ -9,7 +9,7 @@ from os import environ as env from aiohttp import ClientSession, ClientTimeout, DummyCookieJar, TCPConnector, web -from jwcrypto.jwt import JWT, JWKSet, JWTExpired +from jwcrypto.jwt import JWT, JWKSet, JWTExpired, JWTMissingKey from yarl import URL LOG_LEVEL = env.get("LOG_LEVEL", "INFO") @@ -123,22 +123,38 @@ async def fetch_token(req: web.Request): async def audit(req: web.Request, token: str): # This function now supports tokens issued by both Keycloak and Entra ID - j = JWT() - jwks = await get_jwk() + claims = None + for issuer, jwks in await get_jwks(): + j = JWT() + try: + j.deserialize(token, key=jwks) + except JWTMissingKey: + continue + except JWTExpired as e: + app_logger.warning(e) + raise web.HTTPUnauthorized(reason="Token expired") from e + except ValueError as e: + app_logger.warning(e) + raise web.HTTPUnauthorized(reason="Invalid token") from e + + candidate_claims = json.loads(j.claims) + if not isinstance(candidate_claims, dict) or ( + issuer is not None and candidate_claims.get("iss") != issuer + ): + raise web.HTTPUnauthorized(reason="Invalid issuer") + claims = candidate_claims + break + + if claims is None: + raise web.HTTPUnauthorized(reason="Invalid token") + + realm_access = claims.get("realm_access") + keycloak_roles = realm_access.get("roles") if isinstance(realm_access, dict) else None + entra_roles = claims.get("roles") + has_access = ( + isinstance(keycloak_roles, list) and "fp_mdw" in keycloak_roles + ) or (isinstance(entra_roles, list) and "FP/MDW" in entra_roles) - try: - j.deserialize(token, key=jwks) - except ValueError as e: - app_logger.warning(e) - raise web.HTTPUnauthorized(reason="Invalid token") from e - except JWTExpired as e: - app_logger.warning(e) - raise web.HTTPUnauthorized(reason="Token expired") from e - - claims = json.loads(j.claims) - - has_access = "fp_mdw" in claims.get("realm_access", {}).get("roles", []) or "FP/MDW" in claims.get("roles", []) - if not has_access: audit_logger.info( "Access to %s (%s) denied for subject %s (username: %s)", @@ -190,31 +206,43 @@ async def on_shutdown(app): lock = asyncio.Lock() -cached_jwk = None - +cached_jwks = None -async def get_jwk(): - """If we can get the pubkey from a URL, get it from there. - Otherwise, default to the filesystem.""" - # https://login.microsoftonline.com/{tenant}/discovery/v2.0/keys +async def get_jwks(): + """Load and cache the configured issuer/JWKS pairs.""" async with lock: - global cached_jwk - if cached_jwk is not None: - return cached_jwk + global cached_jwks + if cached_jwks is not None: + return cached_jwks + + providers = ( + (env.get("KEYCLOAK_ISSUER"), env.get("KEYCLOAK_JWKS_URL")), + (env.get("ENTRA_ISSUER"), env.get("ENTRA_JWKS_URL")), + ) + keysets = [] + for issuer, url in providers: + if issuer is None and url is None: + continue + if not issuer or not url: + raise RuntimeError("Each JWKS URL must have a matching issuer") - url = env.get("JWKS_URL") - if url: async with SessionManager.session().get( url, timeout=ClientTimeout(sock_connect=3, sock_read=3), skip_auto_headers=["Accept-Encoding"], # we dont need a compressed jwks ) as resp: - payload = await resp.text() - else: - with open(env.get("JWKS_PATH")) as file: - payload = file.read() - cached_jwk = JWKSet.from_json(payload) - return cached_jwk + resp.raise_for_status() + keysets.append((issuer, JWKSet.from_json(await resp.text()))) + + if not keysets: + jwks_path = env.get("JWKS_PATH") + if not jwks_path: + raise RuntimeError("Configure issuer/JWKS URL pairs or JWKS_PATH") + with open(jwks_path) as file: + keysets.append((None, JWKSet.from_json(file.read()))) + + cached_jwks = keysets + return cached_jwks async def check_protection(path, method): From 5c3118a271ac3c23e859c06d3b5c1296b8dc1bdc Mon Sep 17 00:00:00 2001 From: JonaBenja Date: Thu, 12 Nov 2026 12:07:58 +0100 Subject: [PATCH 3/3] Check_claims on authentication proveders --- .gitignore | 1 + compose.yml | 6 +++- jwtproxy/src/server.py | 55 +++++++++++++++++++++++----------- jwtproxy/src/tests/conftest.py | 7 +++++ 4 files changed, 51 insertions(+), 18 deletions(-) diff --git a/.gitignore b/.gitignore index 74908fb7..1af72be0 100644 --- a/.gitignore +++ b/.gitignore @@ -6,6 +6,7 @@ docker-compose.override.y*ml sld/config.json __pycache__ .env +.envrc venv .vscode index.json diff --git a/compose.yml b/compose.yml index 902919be..2c099931 100644 --- a/compose.yml +++ b/compose.yml @@ -8,7 +8,7 @@ services: ports: - "5403:5432" environment: - - POSTGRES_PASSWORD=insecure + - POSTGRES_PASSWORD="${POSTGRES_PASSWORD}" - POSTGRES_DB=postgres command: [ "postgres", "-c", "log_statement=all" ] @@ -26,6 +26,10 @@ services: PUBLIC_PROXY_URL: http://public:8080 JWKS_PATH: /app/test_jwk.json LOG_LEVEL: DEBUG + OAUTH_KEYCLOAK_CLAIMS: "${OAUTH_KEYCLOAK_CLAIMS}" + OAUTH_KEYCLOAK_URL: "${OAUTH_KEYCLOAK_URL}" + OAUTH_ENTRA_CLAIMS: "${OAUTH_ENTRA_CLAIMS}" + OAUTH_ENTRA_URL: "${OAUTH_ENTRA_URL}" # Bare bones 'webserver' to behave as public loadbalancer publicdummy: image: python:3.13-slim-bullseye diff --git a/jwtproxy/src/server.py b/jwtproxy/src/server.py index 136847a9..4796bcb2 100755 --- a/jwtproxy/src/server.py +++ b/jwtproxy/src/server.py @@ -9,6 +9,7 @@ from os import environ as env from aiohttp import ClientSession, ClientTimeout, DummyCookieJar, TCPConnector, web +from jwcrypto.common import JWException from jwcrypto.jwt import JWT, JWKSet, JWTExpired, JWTMissingKey from yarl import URL @@ -123,31 +124,34 @@ async def fetch_token(req: web.Request): async def audit(req: web.Request, token: str): # This function now supports tokens issued by both Keycloak and Entra ID + # First check if the access token is valid claims = None - for issuer, jwks in await get_jwks(): - j = JWT() + for provider in await get_jwks(): try: - j.deserialize(token, key=jwks) + j = JWT( + jwt=token, + key=provider["jwks"], + check_claims=provider["check_claims"], + ) except JWTMissingKey: continue except JWTExpired as e: app_logger.warning(e) raise web.HTTPUnauthorized(reason="Token expired") from e - except ValueError as e: + except (JWException, ValueError) as e: app_logger.warning(e) - raise web.HTTPUnauthorized(reason="Invalid token") from e + continue candidate_claims = json.loads(j.claims) - if not isinstance(candidate_claims, dict) or ( - issuer is not None and candidate_claims.get("iss") != issuer - ): - raise web.HTTPUnauthorized(reason="Invalid issuer") + if not isinstance(candidate_claims, dict): + raise web.HTTPUnauthorized(reason="Invalid token") claims = candidate_claims break if claims is None: raise web.HTTPUnauthorized(reason="Invalid token") + # Then check is the validated access token contains the correct scope realm_access = claims.get("realm_access") keycloak_roles = realm_access.get("roles") if isinstance(realm_access, dict) else None entra_roles = claims.get("roles") @@ -155,6 +159,7 @@ async def audit(req: web.Request, token: str): isinstance(keycloak_roles, list) and "fp_mdw" in keycloak_roles ) or (isinstance(entra_roles, list) and "FP/MDW" in entra_roles) + # Deny requests if no_access: if not has_access: audit_logger.info( "Access to %s (%s) denied for subject %s (username: %s)", @@ -216,15 +221,22 @@ async def get_jwks(): return cached_jwks providers = ( - (env.get("KEYCLOAK_ISSUER"), env.get("KEYCLOAK_JWKS_URL")), - (env.get("ENTRA_ISSUER"), env.get("ENTRA_JWKS_URL")), + (env.get("OAUTH_KEYCLOAK_CLAIMS"), env.get("OAUTH_KEYCLOAK_URL")), + (env.get("OAUTH_ENTRA_CLAIMS"), env.get("OAUTH_ENTRA_URL")), ) keysets = [] - for issuer, url in providers: - if issuer is None and url is None: + for check_claims, url in providers: + if check_claims is None and url is None: continue - if not issuer or not url: - raise RuntimeError("Each JWKS URL must have a matching issuer") + if not check_claims or not url: + raise RuntimeError("Each provider must have a JWKS_URL and CHECK_CLAIMS var") + + check_claims = dict(item.split("=", 1) for item in check_claims.split(",")) + if not check_claims.get("iss"): + raise RuntimeError("check_claims must include an issuer (iss)") + + # + check_claims["exp"] = None async with SessionManager.session().get( url, @@ -232,14 +244,23 @@ async def get_jwks(): skip_auto_headers=["Accept-Encoding"], # we dont need a compressed jwks ) as resp: resp.raise_for_status() - keysets.append((issuer, JWKSet.from_json(await resp.text()))) + provider = { + "check_claims": check_claims, + "jwks": JWKSet.from_json(await resp.text()), + } + keysets.append(provider) if not keysets: jwks_path = env.get("JWKS_PATH") if not jwks_path: raise RuntimeError("Configure issuer/JWKS URL pairs or JWKS_PATH") with open(jwks_path) as file: - keysets.append((None, JWKSet.from_json(file.read()))) + keysets.append( + { + "check_claims": {"exp": None}, + "jwks": JWKSet.from_json(file.read()), + } + ) cached_jwks = keysets return cached_jwks diff --git a/jwtproxy/src/tests/conftest.py b/jwtproxy/src/tests/conftest.py index 8726c5c4..ced93d2f 100644 --- a/jwtproxy/src/tests/conftest.py +++ b/jwtproxy/src/tests/conftest.py @@ -26,6 +26,13 @@ def set_env(): os.environ["PRIVATE_PROXY_URL"] = PRIVATE_PROXY_URL os.environ["PUBLIC_PROXY_URL"] = PUBLIC_PROXY_URL os.environ["JWKS_PATH"] = "test_jwk.json" + for name in ( + "OAUTH_KEYCLOAK_CLAIMS", + "OAUTH_KEYCLOAK_URL", + "OAUTH_ENTRA_CLAIMS", + "OAUTH_ENTRA_URL", + ): + os.environ.pop(name, None) @pytest.fixture