From 0a73b019964f258edec0b4c13d5efc7816c2d167 Mon Sep 17 00:00:00 2001 From: Vahid Tavakkoli Date: Thu, 30 Jul 2026 22:16:02 +0200 Subject: [PATCH 01/25] Use remote Kong-Role gateway and add integration profile --- docker-compose.yml | 66 +++++++++++++++++++++++++++++++++------------- 1 file changed, 47 insertions(+), 19 deletions(-) diff --git a/docker-compose.yml b/docker-compose.yml index c6ba3fa..4f100c2 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,7 +1,7 @@ -networks: +networks: iam_lab_net: driver: bridge - + services: openldap: image: osixia/openldap:latest @@ -11,19 +11,20 @@ services: LDAP_ORGANISATION: "IAM LAB ORG" LDAP_DOMAIN: "iam.lab" LDAP_ADMIN_PASSWORD: "admin" - LDAP_TLS: "false" # deactivate TLS for the LDAP server + LDAP_TLS: "false" volumes: - ./ldap/bootstrap.ldif:/container/service/slapd/assets/config/bootstrap/ldif/custom/50-bootstrap.ldif networks: - iam_lab_net command: --copy-service restart: unless-stopped + openldap-ui: image: osixia/phpldapadmin:latest container_name: iam-lab-openldap-ui environment: PHPLDAPADMIN_LDAP_HOSTS: openldap - PHPLDAPADMIN_HTTPS: "false" # deactivate HTTPS + PHPLDAPADMIN_HTTPS: "false" networks: - iam_lab_net ports: @@ -31,6 +32,7 @@ services: restart: unless-stopped depends_on: - openldap + keycloak: image: keycloak/keycloak:26.2 container_name: iam-lab-keycloak @@ -38,40 +40,42 @@ services: ports: - "9100:8080" volumes: - - ./keycloak/config:/opt/keycloak/data/import + - ./keycloak/config:/opt/keycloak/data/import:ro environment: KC_BOOTSTRAP_ADMIN_USERNAME: admin KC_BOOTSTRAP_ADMIN_PASSWORD: admin KC_HOSTNAME: http://10.0.0.50:9100 - KC_HOSTNAME_PORT: 9100 + KC_HOSTNAME_BACKCHANNEL_DYNAMIC: "true" KC_HEALTH_ENABLED: "true" KC_HOSTNAME_STRICT: "false" - KC_PROXY: "edge" + KC_PROXY_HEADERS: xforwarded networks: [iam_lab_net] kong: - build: ./kong - image: kong-oidc:latest + build: + # Kong and oidc-role are built directly from the separate Kong-Role repository. + # The commit is pinned so the lab remains reproducible. + context: https://github.com/vtavakkoli/Kong-Role.git#8dde703631b0993b55c1c113d5c6335712f9b27d + image: iam-lab-kong-role:2.0.0 container_name: iam-lab-kong ports: - - "9180:8000" # proxy - - "9181:8001" # admin + - "9180:8000" + - "9181:8001" environment: KONG_DATABASE: "off" KONG_DECLARATIVE_CONFIG: /etc/kong/kong.yml KONG_ADMIN_LISTEN: 0.0.0.0:8001 KONG_PROXY_LISTEN: 0.0.0.0:8000 - KONG_PLUGINS: bundled, oidc-role, acl - KONG_X_SESSION_SECRET: "a-very-long-random-string" - KONG_NGINX_LARGE_CLIENT_HEADER_BUFFERS: "'4 16k'" - KONG_LOG_LEVEL: debug + KONG_PLUGINS: bundled,oidc-role,acl + KONG_NGINX_LARGE_CLIENT_HEADER_BUFFERS: "4 16k" + KONG_LOG_LEVEL: notice volumes: - - ./kong/config:/etc/kong + - ./gateway:/etc/kong:ro healthcheck: test: ["CMD", "kong", "health"] interval: 5s timeout: 5s - retries: 10 + retries: 20 restart: unless-stopped networks: [iam_lab_net] @@ -128,11 +132,11 @@ services: CLIENT_SECRET: S3cr3tNumberOne REDIRECT_URI: http://10.0.0.50:9101/auth/callback KONG_API_URL: http://kong:8000 - OIDC_DISABLE_HTTPS: "true" # dev only; switch to false with TLS + OIDC_DISABLE_HTTPS: "true" SERVICE_NAME: WebApp1 COOKIE_NAME: iam-lab-bff-app1 volumes: - - ./data-protection/webapp1:/keys # persist DataProtection keys + - ./data-protection/webapp1:/keys depends_on: kong: condition: service_healthy @@ -162,3 +166,27 @@ services: condition: service_healthy restart: unless-stopped networks: [iam_lab_net] + + integration-test: + image: python:3.12-alpine + container_name: iam-lab-integration-test + profiles: [integration-test] + environment: + KEYCLOAK_URL: http://keycloak:8080 + KEYCLOAK_REALM: IAM_Lab_Realm + KONG_URL: http://kong:8000 + volumes: + - ./tests/integration:/tests:ro + command: ["python", "/tests/test_kong_role.py"] + depends_on: + keycloak: + condition: service_started + kong: + condition: service_healthy + lob1: + condition: service_started + lob2: + condition: service_started + lob3: + condition: service_started + networks: [iam_lab_net] From 1b180bb509eda8c357579e6d206e42bba76de29f Mon Sep 17 00:00:00 2001 From: Vahid Tavakkoli Date: Thu, 30 Jul 2026 22:16:18 +0200 Subject: [PATCH 02/25] Configure Kong-Role v2 authorization --- gateway/kong.yml | 66 ++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 gateway/kong.yml diff --git a/gateway/kong.yml b/gateway/kong.yml new file mode 100644 index 0000000..6c9d6a4 --- /dev/null +++ b/gateway/kong.yml @@ -0,0 +1,66 @@ +_format_version: "3.0" +_comment: "IAM Lab gateway using Kong-Role 2.0.0 and Keycloak realm roles" + +plugins: + - name: oidc-role + config: + auth_mode: jwt + client_id: kong + discovery: http://keycloak:8080/realms/IAM_Lab_Realm/.well-known/openid-configuration + expected_issuer: http://10.0.0.50:9100/realms/IAM_Lab_Realm + allowed_signing_algorithms: + - RS256 + ssl_verify: false + principal_claim: sub + username_claim: preferred_username + authorization_claims: + - realm_access.roles + require_principal: true + require_authorization_claim: true + expose_userinfo: false + expose_id_token: false + expose_access_token: false + +services: + - name: lob1-service + url: http://lob1:8080 + + - name: lob2-service + url: http://lob2:8080 + + - name: lob3-service + url: http://lob3:8080 + +routes: + - name: lob1-route + service: lob1-service + paths: + - /lob1 + plugins: + - name: acl + config: + allow: + - lob1-user + hide_groups_header: true + + - name: lob2-route + service: lob2-service + paths: + - /lob2 + plugins: + - name: acl + config: + allow: + - lob2-user + hide_groups_header: true + + - name: lob3-route + service: lob3-service + paths: + - /lob3 + plugins: + - name: acl + config: + allow: + - lob3-user + hide_groups_header: true From 7978a641603529df8ec451787eb745a8183ef746 Mon Sep 17 00:00:00 2001 From: Vahid Tavakkoli Date: Thu, 30 Jul 2026 22:17:23 +0200 Subject: [PATCH 03/25] Add Keycloak Kong-Role integration tests --- tests/integration/test_kong_role.py | 243 ++++++++++++++++++++++++++++ 1 file changed, 243 insertions(+) create mode 100644 tests/integration/test_kong_role.py diff --git a/tests/integration/test_kong_role.py b/tests/integration/test_kong_role.py new file mode 100644 index 0000000..e2d6056 --- /dev/null +++ b/tests/integration/test_kong_role.py @@ -0,0 +1,243 @@ +#!/usr/bin/env python3 +"""End-to-end test for Keycloak -> Kong-Role -> Kong ACL -> LOB services.""" + +from __future__ import annotations + +import base64 +import json +import os +import sys +import time +import urllib.error +import urllib.parse +import urllib.request +from typing import Any + +KEYCLOAK_URL = os.getenv("KEYCLOAK_URL", "http://keycloak:8080").rstrip("/") +REALM = os.getenv("KEYCLOAK_REALM", "IAM_Lab_Realm") +KONG_URL = os.getenv("KONG_URL", "http://kong:8000").rstrip("/") +TEST_CLIENT = "kong-role-integration-test" +PUBLIC_ISSUER = f"http://10.0.0.50:9100/realms/{REALM}" +TIMEOUT_SECONDS = 240 + + +def request( + method: str, + url: str, + *, + data: bytes | None = None, + headers: dict[str, str] | None = None, + timeout: int = 10, +) -> tuple[int, str]: + req = urllib.request.Request(url, data=data, headers=headers or {}, method=method) + try: + with urllib.request.urlopen(req, timeout=timeout) as response: + return response.status, response.read().decode("utf-8") + except urllib.error.HTTPError as exc: + return exc.code, exc.read().decode("utf-8", errors="replace") + except (urllib.error.URLError, TimeoutError) as exc: + return 0, str(exc) + + +def post_form(url: str, values: dict[str, str]) -> tuple[int, dict[str, Any]]: + status, body = request( + "POST", + url, + data=urllib.parse.urlencode(values).encode("utf-8"), + headers={"Content-Type": "application/x-www-form-urlencoded"}, + ) + try: + payload = json.loads(body) + except json.JSONDecodeError: + payload = {"raw": body} + return status, payload + + +def wait_until_ready() -> None: + deadline = time.time() + TIMEOUT_SECONDS + discovery = f"{KEYCLOAK_URL}/realms/{REALM}/.well-known/openid-configuration" + while time.time() < deadline: + status, _ = request("GET", discovery) + if status == 200: + print("[ready] Keycloak realm is available") + return + time.sleep(2) + raise RuntimeError("Keycloak did not become ready") + + +def admin_token() -> str: + status, payload = post_form( + f"{KEYCLOAK_URL}/realms/master/protocol/openid-connect/token", + { + "grant_type": "password", + "client_id": "admin-cli", + "username": "admin", + "password": "admin", + }, + ) + if status != 200 or "access_token" not in payload: + raise RuntimeError(f"Unable to obtain Keycloak admin token: {status} {payload}") + return str(payload["access_token"]) + + +def ensure_test_client(token: str) -> None: + headers = {"Authorization": f"Bearer {token}"} + query = urllib.parse.urlencode({"clientId": TEST_CLIENT}) + clients_url = f"{KEYCLOAK_URL}/admin/realms/{REALM}/clients" + status, body = request("GET", f"{clients_url}?{query}", headers=headers) + if status != 200: + raise RuntimeError(f"Unable to inspect Keycloak clients: {status} {body}") + + existing = json.loads(body) + if existing: + print(f"[setup] Keycloak client {TEST_CLIENT} already exists") + return + + client = { + "clientId": TEST_CLIENT, + "name": "Kong-Role integration test", + "enabled": True, + "protocol": "openid-connect", + "publicClient": True, + "standardFlowEnabled": False, + "directAccessGrantsEnabled": True, + "serviceAccountsEnabled": False, + "fullScopeAllowed": True, + "protocolMappers": [ + { + "name": "realm-roles", + "protocol": "openid-connect", + "protocolMapper": "oidc-usermodel-realm-role-mapper", + "consentRequired": False, + "config": { + "multivalued": "true", + "claim.name": "realm_access.roles", + "jsonType.label": "String", + "access.token.claim": "true", + "id.token.claim": "false", + "userinfo.token.claim": "false", + "introspection.token.claim": "true", + }, + } + ], + } + status, body = request( + "POST", + clients_url, + data=json.dumps(client).encode("utf-8"), + headers={**headers, "Content-Type": "application/json"}, + ) + if status not in (201, 204): + raise RuntimeError(f"Unable to create Keycloak test client: {status} {body}") + print(f"[setup] Created Keycloak client {TEST_CLIENT}") + + +def user_token(username: str) -> str: + status, payload = post_form( + f"{KEYCLOAK_URL}/realms/{REALM}/protocol/openid-connect/token", + { + "grant_type": "password", + "client_id": TEST_CLIENT, + "username": username, + "password": username, + "scope": "openid", + }, + ) + if status != 200 or "access_token" not in payload: + raise RuntimeError(f"Unable to obtain token for {username}: {status} {payload}") + return str(payload["access_token"]) + + +def decode_payload(token: str) -> dict[str, Any]: + payload = token.split(".")[1] + payload += "=" * (-len(payload) % 4) + return json.loads(base64.urlsafe_b64decode(payload).decode("utf-8")) + + +def call_lob(path: str, token: str | None = None) -> tuple[int, str]: + headers = {"Authorization": f"Bearer {token}"} if token else {} + return request("GET", f"{KONG_URL}{path}", headers=headers) + + +def wait_for_gateway(token: str) -> None: + deadline = time.time() + TIMEOUT_SECONDS + while time.time() < deadline: + status, _ = call_lob("/lob1", token) + if status == 200: + print("[ready] Kong-Role and LOB services are available") + return + time.sleep(2) + raise RuntimeError("Kong-Role gateway did not become ready") + + +def assert_status(label: str, actual: int, expected: int, body: str) -> None: + if actual != expected: + raise AssertionError(f"{label}: expected HTTP {expected}, got {actual}: {body}") + print(f"[pass] {label}: HTTP {actual}") + + +def main() -> int: + wait_until_ready() + ensure_test_client(admin_token()) + + expected_roles = { + "alice": {"lob1-user"}, + "bob": {"lob1-user", "lob2-user"}, + "charlie": {"lob1-user", "lob2-user", "lob3-user"}, + } + tokens: dict[str, str] = {} + for username, required_roles in expected_roles.items(): + token = user_token(username) + claims = decode_payload(token) + actual_roles = set(claims.get("realm_access", {}).get("roles", [])) + if not required_roles.issubset(actual_roles): + raise AssertionError( + f"{username} token roles are incomplete: expected {required_roles}, got {actual_roles}" + ) + if claims.get("iss") != PUBLIC_ISSUER: + raise AssertionError( + f"{username} token issuer mismatch: {claims.get('iss')} != {PUBLIC_ISSUER}" + ) + tokens[username] = token + print(f"[pass] {username} token contains {sorted(required_roles)}") + + wait_for_gateway(tokens["alice"]) + + status, body = call_lob("/lob1") + assert_status("missing token is rejected", status, 401, body) + + status, body = call_lob("/lob1", "not-a-jwt") + assert_status("invalid token is rejected", status, 401, body) + + cases = [ + ("alice", "/lob1", 200, "LOB-1"), + ("alice", "/lob2", 403, None), + ("alice", "/lob3", 403, None), + ("bob", "/lob1", 200, "LOB-1"), + ("bob", "/lob2", 200, "LOB-2"), + ("bob", "/lob3", 403, None), + ("charlie", "/lob1", 200, "LOB-1"), + ("charlie", "/lob2", 200, "LOB-2"), + ("charlie", "/lob3", 200, "LOB-3"), + ] + + for username, path, expected_status, expected_service in cases: + status, body = call_lob(path, tokens[username]) + assert_status(f"{username} -> {path}", status, expected_status, body) + if expected_service: + payload = json.loads(body) + if payload.get("service") != expected_service: + raise AssertionError( + f"{username} -> {path}: expected {expected_service}, got {payload}" + ) + + print("\nAll Kong-Role integration tests passed.") + return 0 + + +if __name__ == "__main__": + try: + sys.exit(main()) + except Exception as exc: # noqa: BLE001 - test runner should print complete failure + print(f"\n[FAIL] {exc}", file=sys.stderr) + sys.exit(1) From 316046a9da63c5e30a9bf0024491ae8c50e16a24 Mon Sep 17 00:00:00 2001 From: Vahid Tavakkoli Date: Thu, 30 Jul 2026 22:17:36 +0200 Subject: [PATCH 04/25] Run Kong-Role end-to-end integration in CI --- .github/workflows/kong-role-integration.yml | 31 +++++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 .github/workflows/kong-role-integration.yml diff --git a/.github/workflows/kong-role-integration.yml b/.github/workflows/kong-role-integration.yml new file mode 100644 index 0000000..a6fa07b --- /dev/null +++ b/.github/workflows/kong-role-integration.yml @@ -0,0 +1,31 @@ +name: kong-role-integration + +on: + pull_request: + workflow_dispatch: + +permissions: + contents: read + +jobs: + integration: + runs-on: ubuntu-latest + timeout-minutes: 25 + steps: + - uses: actions/checkout@v4 + + - name: Build and run IAM integration test + run: | + docker compose --profile integration-test up \ + --build \ + --abort-on-container-exit \ + --exit-code-from integration-test \ + integration-test + + - name: Show service logs on failure + if: failure() + run: docker compose --profile integration-test logs --no-color + + - name: Stop lab + if: always() + run: docker compose --profile integration-test down --volumes --remove-orphans From 76efe2f35aa92249bd592b9bbc84e3f66b8b5267 Mon Sep 17 00:00:00 2001 From: Vahid Tavakkoli Date: Thu, 30 Jul 2026 22:18:07 +0200 Subject: [PATCH 05/25] Document external Kong-Role integration and tests --- README.md | 193 ++++++++++++++++++++++++------------------------------ 1 file changed, 86 insertions(+), 107 deletions(-) diff --git a/README.md b/README.md index 31e1c7a..a98629e 100644 --- a/README.md +++ b/README.md @@ -1,158 +1,137 @@ -# IAM Lab: Kong, Keycloak, LDAP & .NET Demo +# IAM Lab: Kong-Role, Keycloak, LDAP & .NET Demo > **Written by Vahid Tavakkoli, 2026** -A hands-on **Identity and Access Management (IAM) lab** that demonstrates how to combine: +A hands-on Identity and Access Management lab demonstrating: -- **Keycloak** for identity, OpenID Connect, and role assignments -- **Kong Gateway** (DB-less) for API routing and authorization checks -- **OpenLDAP** as a simple user directory source -- **.NET 8** backend-for-frontend (BFF-style) apps and protected LOB APIs +- **Keycloak** for identity, OpenID Connect, and realm-role assignments +- **Kong Community Gateway** with the external [Kong-Role](https://github.com/vtavakkoli/Kong-Role) plugin +- **Kong ACL** for route-level authorization using authenticated JWT role groups +- **OpenLDAP** as a user-directory source +- **.NET 8** web applications and protected LOB APIs -This repository is designed as a learning and experimentation environment for IAM concepts such as SSO, role-based API protection, and token-based service access. +## Demo disclaimer ---- +This repository is a local lab. Credentials, HTTP endpoints, and hostnames are intentionally developer-friendly and are not production-safe. See [DISCLAIMER.md](DISCLAIMER.md). -## ⚠️ Demo / Lab Disclaimer +## Architecture -This project is a **demo/lab environment only**. - -- Credentials, hostnames, and secrets are intentionally simplified. -- Configuration is intentionally developer-friendly, **not production-safe**. -- Do **not** deploy this repository directly in production. - -See [DISCLAIMER.md](DISCLAIMER.md) for details. - ---- - -## Why this repository exists - -This lab helps engineers and architects quickly test IAM integration patterns without building all components from scratch. It is useful for: - -- local IAM proof-of-concepts -- role and policy experiments -- gateway/OIDC plugin prototyping -- onboarding and demo workshops - ---- - -## Architecture at a glance - -The stack is orchestrated with Docker Compose and includes: +```text +OpenLDAP ──► Keycloak ──JWT──► Kong-Role ──authenticated groups──► Kong ACL + │ │ + └──────────────────────────────┘ + │ + LOB1 / LOB2 / LOB3 +``` -- **openldap**: LDAP directory with demo users -- **openldap-ui**: phpLDAPadmin for browsing LDAP -- **keycloak**: Identity Provider with preloaded realm -- **kong**: API Gateway with custom `oidc-role` plugin -- **lob1 / lob2 / lob3**: protected .NET LOB APIs -- **webapp1 / webapp2**: OIDC-enabled .NET web apps +The local copy of the Kong plugin has been removed. Docker Compose builds the gateway directly from a pinned commit of the separate `Kong-Role` repository: -![System Architecture](system_architecture.svg) +```text +Kong-Role commit: 8dde703631b0993b55c1c113d5c6335712f9b27d +Plugin version: 2.0.0 +``` ---- +The pin makes the lab reproducible while keeping the gateway implementation maintained in one repository. ## Repository structure ```text . ├── docker-compose.yml +├── gateway/ +│ └── kong.yml ├── keycloak/ │ └── config/IAM_Lab_Realm.json -├── kong/ -│ ├── config/kong.yml -│ └── oidc/ # custom oidc-role plugin ├── ldap/ │ └── bootstrap.ldif ├── lob-services/ -│ ├── Program.cs -│ └── LOB.csproj ├── WebApp1/ -│ ├── Program.cs -│ └── wwwroot/index.html ├── WebApp2/ -│ ├── Program.cs -│ └── wwwroot/index.html -└── docs and governance files +├── tests/integration/ +│ └── test_kong_role.py +└── .github/workflows/ + └── kong-role-integration.yml ``` ---- +## Start the lab -## Quick start +```bash +docker compose up --build +``` -1. **Clone and enter repository** +Primary endpoints: - ```bash - git clone - cd IAM-LAB - ``` +- Keycloak: `http://localhost:9100` +- phpLDAPadmin: `http://localhost:9150` +- Kong proxy: `http://localhost:9180` +- Kong Admin API: `http://localhost:9181` +- WebApp1: `http://localhost:9101` +- WebApp2: `http://localhost:9102` -2. **Build and run services** +The sample configuration still uses `10.0.0.50` as the browser-facing host. Change `KC_HOSTNAME`, the web-app public URLs, and redirect URIs in `docker-compose.yml` when your development host uses another address. - ```bash - docker compose up --build - ``` +## Kong-Role v2 behavior -3. **Open primary endpoints** +The gateway uses one global `oidc-role` plugin instance to: - - Keycloak: `http://localhost:9100` - - phpLDAPadmin: `http://localhost:9150` - - Kong Proxy: `http://localhost:9180` - - WebApp1: `http://localhost:9101` - - WebApp2: `http://localhost:9102` +1. validate bearer JWT signatures through Keycloak discovery/JWKS; +2. verify the expected issuer and required principal; +3. extract all values from `realm_access.roles`; +4. publish the roles as Kong authenticated groups; +5. let each route ACL return `200` or `403` based on the required LOB role. -4. **Sign in and test access** +No synthetic Kong consumers are required. - - log in through one of the web apps - - call LOB endpoints through Kong - - adjust user roles in Keycloak and retest +Role matrix: ---- +| User | LOB1 | LOB2 | LOB3 | +|---|---:|---:|---:| +| alice | allowed | denied | denied | +| bob | allowed | allowed | denied | +| charlie | allowed | allowed | allowed | -## Core functional behavior (unchanged) +## Run the integration test -- OIDC login flow with Keycloak for web apps -- Cookie/session handling in .NET BFF apps -- Kong route-level role checks via custom plugin -- LOB APIs returning simple JSON responses +The test creates a temporary direct-grant Keycloak client through the Admin API, requests real JWTs for Alice, Bob, and Charlie, and calls every protected route through Kong. ---- +```bash +docker compose --profile integration-test up \ + --build \ + --abort-on-container-exit \ + --exit-code-from integration-test \ + integration-test +``` -## Demo credentials (non-production) +It verifies: -- Keycloak admin: `admin` / `admin` -- LDAP admin DN: `cn=admin,dc=iam,dc=lab` -- LDAP admin password: `admin` +- missing token returns `401`; +- malformed token returns `401`; +- Keycloak tokens contain the expected issuer and realm roles; +- allowed role/route combinations return `200` and reach the correct LOB service; +- denied role/route combinations return `403`; +- users with multiple roles retain all authorization groups. -Demo user examples are defined in `ldap/bootstrap.ldif`. +Clean up afterward: ---- +```bash +docker compose --profile integration-test down --volumes --remove-orphans +``` + +The same test runs in GitHub Actions through `.github/workflows/kong-role-integration.yml`. + +## Demo credentials + +- Keycloak admin: `admin` / `admin` +- LDAP admin DN: `cn=admin,dc=iam,dc=lab` +- LDAP password: `admin` +- Users: `alice/alice`, `bob/bob`, and `charlie/charlie` -## Governance and publication-readiness files +## Governance - [CONTRIBUTING.md](CONTRIBUTING.md) - [CHANGELOG.md](CHANGELOG.md) - [ROADMAP.md](ROADMAP.md) - [SECURITY.md](SECURITY.md) - [CODE_OF_CONDUCT.md](CODE_OF_CONDUCT.md) -- [LICENSE](LICENSE) *(MIT)* +- [LICENSE](LICENSE) - [DISCLAIMER.md](DISCLAIMER.md) - ---- - -## Roadmap - -See [ROADMAP.md](ROADMAP.md) for planned improvements. - ---- - -## Contributing - -Please read [CONTRIBUTING.md](CONTRIBUTING.md) before opening pull requests. - ---- - -## License - -This repository is licensed under the **MIT License**. - -See [LICENSE](LICENSE). From dc9ab27199d7e1cb073e25493b7fcff62eab6590 Mon Sep 17 00:00:00 2001 From: Vahid Tavakkoli Date: Thu, 30 Jul 2026 22:18:15 +0200 Subject: [PATCH 06/25] Remove local Kong image build --- kong/Dockerfile | 15 --------------- 1 file changed, 15 deletions(-) delete mode 100644 kong/Dockerfile diff --git a/kong/Dockerfile b/kong/Dockerfile deleted file mode 100644 index 8bd0c9d..0000000 --- a/kong/Dockerfile +++ /dev/null @@ -1,15 +0,0 @@ -FROM kong/kong:latest - -USER root - -# Install required tools and build dependencies -RUN apt-get update && \ - apt-get install -y luarocks && \ - rm -rf /var/lib/apt/lists/* - -# Install kong-oidc -RUN luarocks install kong-oidc -COPY oidc /usr/local/share/lua/5.1/kong/plugins/oidc-role -RUN chown -R kong:kong /usr/local/share/lua/5.1/kong/plugins/oidc-role -# Switch back to the Kong user -USER kong \ No newline at end of file From 74270be9411041198880a26181501a5ee652c011 Mon Sep 17 00:00:00 2001 From: Vahid Tavakkoli Date: Thu, 30 Jul 2026 22:18:22 +0200 Subject: [PATCH 07/25] Remove legacy local Kong configuration --- kong/config/kong.yml | 96 -------------------------------------------- 1 file changed, 96 deletions(-) delete mode 100644 kong/config/kong.yml diff --git a/kong/config/kong.yml b/kong/config/kong.yml deleted file mode 100644 index a501c14..0000000 --- a/kong/config/kong.yml +++ /dev/null @@ -1,96 +0,0 @@ -_format_version: "3.0" -_comment: "DB-less Kong config for IAM Lab: Keycloak OIDC + ACL" - -services: - - name: lob1-service - url: http://lob1:8080 - plugins: - - name: oidc-role - config: - discovery: http://10.0.0.50:9100/realms/IAM_Lab_Realm/.well-known/openid-configuration - introspection_endpoint: http://keycloak:8080/realms/IAM_Lab_Realm/protocol/openid-connect/token/introspect - client_id: kong - client_secret: S3cr3tKong - scope: openid - ssl_verify: "false" - bearer_only: "yes" - consumer_claim: realm_access.roles - consumer_by: custom_id - use_jwks: "yes" - - - name: lob2-service - url: http://lob2:8080 - plugins: - - name: oidc-role - config: - discovery: http://10.0.0.50:9100/realms/IAM_Lab_Realm/.well-known/openid-configuration - introspection_endpoint: http://keycloak:8080/realms/IAM_Lab_Realm/protocol/openid-connect/token/introspect - client_id: kong - client_secret: S3cr3tKong - scope: openid - ssl_verify: "false" - bearer_only: "yes" - consumer_claim: realm_access.roles - consumer_by: custom_id - use_jwks: "yes" - - - - name: lob3-service - url: http://lob3:8080 - plugins: - - name: oidc-role - config: - discovery: http://10.0.0.50:9100/realms/IAM_Lab_Realm/.well-known/openid-configuration - introspection_endpoint: http://keycloak:8080/realms/IAM_Lab_Realm/protocol/openid-connect/token/introspect - client_id: kong - client_secret: S3cr3tKong - scope: openid - ssl_verify: "false" - bearer_only: "yes" - consumer_claim: realm_access.roles - consumer_by: custom_id - use_jwks: "yes" - -routes: - - name: lob1-route - service: lob1-service - paths: [ /lob1 ] - plugins: - - name: acl - config: - allow: - - lob1-user - - - name: lob2-route - service: lob2-service - paths: [ /lob2 ] - plugins: - - name: acl - config: - allow: - - lob2-user - - - name: lob3-route - service: lob3-service - paths: [ /lob3 ] - plugins: - - name: acl - config: - allow: - - lob3-user - -consumers: - - username: role-lob1 - custom_id: lob1-user - acls: - - group: lob1-user - - - username: role-lob2 - custom_id: lob2-user - acls: - - group: lob2-user - - - username: role-lob3 - custom_id: lob3-user - acls: - - group: lob3-user \ No newline at end of file From c1d1f84a4d281f41f5385aff87ecd439263d3f78 Mon Sep 17 00:00:00 2001 From: Vahid Tavakkoli Date: Thu, 30 Jul 2026 22:18:31 +0200 Subject: [PATCH 08/25] Remove duplicated local oidc-role handler --- kong/oidc/handler.lua | 263 ------------------------------------------ 1 file changed, 263 deletions(-) delete mode 100644 kong/oidc/handler.lua diff --git a/kong/oidc/handler.lua b/kong/oidc/handler.lua deleted file mode 100644 index 7f11470..0000000 --- a/kong/oidc/handler.lua +++ /dev/null @@ -1,263 +0,0 @@ -local OidcHandler = { - VERSION = "1.0.0", - PRIORITY = 1000, -} -local utils = require("kong.plugins.oidc-role.utils") -local filter = require("kong.plugins.oidc-role.filter") -local session = require("kong.plugins.oidc-role.session") -local cjson = require("cjson") - -function OidcHandler:access(config) - ngx.log(ngx.DEBUG, "[oidc-role] effective config → ", cjson.encode(config)) - local oidcConfig = utils.get_options(config, ngx) - ngx.log(ngx.DEBUG, "[oidc-role] access() start, uri=", ngx.var.request_uri) - if oidcConfig.skip_already_auth_requests and kong.client.get_credential() then - ngx.log(ngx.DEBUG, "[oidc-role] skipping already authenticated request") - return - end - - if filter.shouldProcessRequest(oidcConfig) then - ngx.log(ngx.DEBUG, "[oidc-role] shouldProcessRequest → true") - session.configure(config) - handle(oidcConfig) - else - ngx.log(ngx.DEBUG, "[oidc-role] shouldProcessRequest → false, uri=", ngx.var.request_uri) - end - - ngx.log(ngx.DEBUG, "[oidc-role] access() done") -end - --- helper to get nested values from a table via “a.b.c” paths -local function get_nested(tbl, path) - local cur = tbl - for key in path:gmatch("[^%.]+") do - if type(cur) ~= "table" then - return nil - end - cur = cur[key] - end - return cur -end - ---─── consumer mapping helper ─────────────────────────────────────────── -local function map_consumer(oidcConfig, response) - ngx.log(ngx.DEBUG, "[oidc-role] map_consumer() begin") - if not (oidcConfig.consumer_claim and oidcConfig.consumer_by) then - ngx.log(ngx.DEBUG, "[oidc-role] no consumer_claim/consumer_by configured, skipping map_consumer") - return - end - - ngx.log(ngx.DEBUG, "[oidc-role] consumer_claim=", oidcConfig.consumer_claim, - " consumer_by=", oidcConfig.consumer_by) - - -- 1) pull out the claim (supports deep paths like "realm_access.roles") - local claim_val = get_nested(response, oidcConfig.consumer_claim) - ngx.log(ngx.DEBUG, "[oidc-role] token claim[", oidcConfig.consumer_claim, "] = ", tostring(claim_val)) - if not claim_val then - ngx.log(ngx.WARN, "[oidc-role] claim value is nil, cannot map consumer") - return - end - - -- 2) build a list of candidates (single-value or array) - local candidates = {} - if type(claim_val) == "table" then - for _, v in ipairs(claim_val) do - table.insert(candidates, v) - end - - -- 2a) prioritize the role matching the request path (e.g. "/lob1" → "lob1-user") - local seg = ngx.var.request_uri:match("^/([^/]+)") - if seg then - local desired = seg .. "-user" - for i, role in ipairs(candidates) do - if role == desired then - table.remove(candidates, i) - table.insert(candidates, 1, role) - ngx.log(ngx.DEBUG, "[oidc-role] prioritized role → ", desired) - break - end - end - end - else - table.insert(candidates, claim_val) - end - - -- 3) try each candidate in order - for _, claim_item in ipairs(candidates) do - local consumer, err - local field = oidcConfig.consumer_by - - if field == "username" then - consumer, err = kong.db.consumers:select_by_username(claim_item) - elseif field == "custom_id" then - consumer, err = kong.db.consumers:select_by_custom_id(claim_item) - elseif field == "id" then - consumer, err = kong.db.consumers:select({ id = claim_item }) - else - ngx.log(ngx.ERR, "[oidc-role] unsupported consumer_by=", field) - return - end - - if err then - ngx.log(ngx.ERR, "[oidc-role] error selecting consumer by ", field, ": ", err) - return - elseif consumer then - ngx.log(ngx.DEBUG, "[oidc-role] mapped to consumer id=", consumer.id, - " username=", consumer.username, - " custom_id=", consumer.custom_id) - return kong.client.authenticate(consumer, nil) - else - ngx.log(ngx.DEBUG, "[oidc-role] no consumer found for ", field, "=", claim_item, ", trying next") - end - end - - ngx.log(ngx.WARN, "[oidc-role] map_consumer(): no matching consumer for any claim value") -end - ---─── main handler ──────────────────────────────────────────────────────────── -function handle(oidcConfig) - ngx.log(ngx.DEBUG, "[oidc-role] handle() start") - - -- 1) Bearer JWT - if oidcConfig.bearer_jwt_auth_enable then - ngx.log(ngx.DEBUG, "[oidc-role] branch=verify_bearer_jwt") - local resp = verify_bearer_jwt(oidcConfig) - if resp then - ngx.log(ngx.DEBUG, "[oidc-role] verify_bearer_jwt → success") - utils.setCredentials(resp) - utils.injectGroups(resp, oidcConfig.groups_claim) - utils.injectHeaders(oidcConfig.header_names, oidcConfig.header_claims, {resp}) - if not oidcConfig.disable_userinfo_header then - utils.injectUser(resp, oidcConfig.userinfo_header_name) - end - map_consumer(oidcConfig, resp) - ngx.log(ngx.DEBUG, "[oidc-role] handle() end (bearer_jwt)") - return - end - ngx.log(ngx.DEBUG, "[oidc-role] verify_bearer_jwt → no token or invalid") - end - - -- 2) Introspection - if oidcConfig.introspection_endpoint then - ngx.log(ngx.DEBUG, "[oidc-role] branch=introspect") - local resp = introspect(oidcConfig) - if resp then - ngx.log(ngx.DEBUG, "[oidc-role] introspect → success") - utils.setCredentials(resp) - utils.injectGroups(resp, oidcConfig.groups_claim) - utils.injectHeaders(oidcConfig.header_names, oidcConfig.header_claims, {resp}) - if not oidcConfig.disable_userinfo_header then - utils.injectUser(resp, oidcConfig.userinfo_header_name) - end - map_consumer(oidcConfig, resp) - ngx.log(ngx.DEBUG, "[oidc-role] handle() end (introspect)") - return - end - ngx.log(ngx.DEBUG, "[oidc-role] introspect → no token or invalid") - end - - -- 3) Code flow - ngx.log(ngx.DEBUG, "[oidc-role] branch=make_oidc") - local full = make_oidc(oidcConfig) - if full then - ngx.log(ngx.DEBUG, "[oidc-role] authenticate() → success") - local cred = full.user or full.id_token - utils.setCredentials(cred) - if full.user and full.user[oidcConfig.groups_claim] then - utils.injectGroups(full.user, oidcConfig.groups_claim) - elseif full.id_token then - utils.injectGroups(full.id_token, oidcConfig.groups_claim) - end - utils.injectHeaders(oidcConfig.header_names, oidcConfig.header_claims, {full.user, full.id_token}) - if not oidcConfig.disable_userinfo_header and full.user then - utils.injectUser(full.user, oidcConfig.userinfo_header_name) - end - if not oidcConfig.disable_access_token_header and full.access_token then - utils.injectAccessToken(full.access_token, oidcConfig.access_token_header_name, oidcConfig.access_token_as_bearer) - end - if not oidcConfig.disable_id_token_header and full.id_token then - utils.injectIDToken(full.id_token, oidcConfig.id_token_header_name) - end - - map_consumer(oidcConfig, cred) - ngx.log(ngx.DEBUG, "[oidc-role] handle() end (code flow)") - return - end - - ngx.log(ngx.DEBUG, "[oidc-role] handle() end (no response)") -end - - ---─── helper functions below unchanged ─────────────────────────────────────── - -function make_oidc(oidcConfig) - ngx.log(ngx.DEBUG, "[oidc-role] make_oidc() calling authenticate: ", ngx.var.request_uri) - local action = (oidcConfig.unauth_action ~= "auth") and "deny" or "auth" - local res, err = require("resty.openidc").authenticate(oidcConfig, ngx.var.request_uri, action) - if err then - if err == "unauthorized request" then - return kong.response.error(ngx.HTTP_UNAUTHORIZED) - end - if oidcConfig.recovery_page_path then - ngx.log(ngx.DEBUG, "[oidc-role] redirect to recovery: ", oidcConfig.recovery_page_path) - ngx.redirect(oidcConfig.recovery_page_path) - end - return kong.response.error(ngx.HTTP_INTERNAL_SERVER_ERROR) - end - return res -end - - -function introspect(oidcConfig) - if utils.has_bearer_access_token() or oidcConfig.bearer_only == "yes" then - ngx.log(ngx.DEBUG, "[oidc-role] introspect() verifying bearer token") - local res, err = (oidcConfig.use_jwks == "yes") - and require("resty.openidc").bearer_jwt_verify(oidcConfig) - or require("resty.openidc").introspect(oidcConfig) - if err then - ngx.log(ngx.WARN, "[oidc-role] introspect error: ", err) - if oidcConfig.bearer_only == "yes" then - ngx.header["WWW-Authenticate"] = 'Bearer realm="'..oidcConfig.realm..'",error="'..err..'"' - return kong.response.error(ngx.HTTP_UNAUTHORIZED) - end - return nil - end - ngx.log(ngx.DEBUG, "[oidc-role] introspect() success") - return res - end - return nil -end - - -function verify_bearer_jwt(oidcConfig) - if not utils.has_bearer_access_token() then - ngx.log(ngx.DEBUG, "[oidc-role] verify_bearer_jwt() no bearer token") - return nil - end - ngx.log(ngx.DEBUG, "[oidc-role] verify_bearer_jwt() start") - -- ... rest unchanged ... - local json, err = require("resty.openidc").bearer_jwt_verify({ - accept_none_alg = false, - accept_unsupported_alg = false, - token_signing_alg_values_expected = oidcConfig.bearer_jwt_auth_signing_algs, - discovery = oidcConfig.discovery, - timeout = oidcConfig.timeout, - ssl_verify = oidcConfig.ssl_verify, - }, { - iss = require("resty.jwt-validators").equals(oidcConfig.discovery), - sub = require("resty.jwt-validators").required(), - aud = function(val) return utils.has_common_item(val, oidcConfig.client_id) end, - exp = require("resty.jwt-validators").is_not_expired(), - iat = require("resty.jwt-validators").required(), - nbf = require("resty.jwt-validators").opt_is_not_before(), - }) - if err then - ngx.log(ngx.ERR, "[oidc-role] bearer_jwt_verify failed: ", err) - return nil - end - ngx.log(ngx.DEBUG, "[oidc-role] bearer_jwt_verify() success") - return json -end - - -return OidcHandler From d35ab37320e5caf6391bca58f09a431c32692777 Mon Sep 17 00:00:00 2001 From: Vahid Tavakkoli Date: Thu, 30 Jul 2026 22:18:37 +0200 Subject: [PATCH 09/25] Remove duplicated local oidc-role filter --- kong/oidc/filter.lua | 17 ----------------- 1 file changed, 17 deletions(-) delete mode 100644 kong/oidc/filter.lua diff --git a/kong/oidc/filter.lua b/kong/oidc/filter.lua deleted file mode 100644 index 584a0c7..0000000 --- a/kong/oidc/filter.lua +++ /dev/null @@ -1,17 +0,0 @@ -local M = {} - -local function shouldIgnoreRequest(patterns) - if (patterns) then - for _, pattern in ipairs(patterns) do - local isMatching = not (string.find(ngx.var.uri, pattern) == nil) - if (isMatching) then return true end - end - end - return false -end - -function M.shouldProcessRequest(config) - return not shouldIgnoreRequest(config.filters) -end - -return M From 7be6802dbc20cd0bd5534718aeebc1fb639a746b Mon Sep 17 00:00:00 2001 From: Vahid Tavakkoli Date: Thu, 30 Jul 2026 22:18:42 +0200 Subject: [PATCH 10/25] Remove duplicated local oidc-role schema --- kong/oidc/schema.lua | 328 ------------------------------------------- 1 file changed, 328 deletions(-) delete mode 100644 kong/oidc/schema.lua diff --git a/kong/oidc/schema.lua b/kong/oidc/schema.lua deleted file mode 100644 index 4198a8f..0000000 --- a/kong/oidc/schema.lua +++ /dev/null @@ -1,328 +0,0 @@ -local typedefs = require "kong.db.schema.typedefs" - -return { - name = "oidc-role", - fields = { - { - -- this plugin will only be applied to Services or Routes - consumer = typedefs.no_consumer - }, - { - -- this plugin will only run within Nginx HTTP module - protocols = typedefs.protocols_http - }, - { - config = { - type = "record", - fields = { - { - client_id = { - type = "string", - required = true - } - }, - { - client_secret = { - type = "string", - required = true - } - }, - { - discovery = { - type = "string", - required = true, - default = "https://.well-known/openid-configuration" - } - }, - { - introspection_endpoint = { - type = "string", - required = false - } - }, - { - introspection_endpoint_auth_method = { - type = "string", - required = false - } - }, - { - introspection_cache_ignore = { - type = "string", - required = true, - default = "no" - } - }, - { - timeout = { - type = "number", - required = false - } - }, - { - bearer_only = { - type = "string", - required = true, - default = "no" - } - }, - { - realm = { - type = "string", - required = true, - default = "kong" - } - }, - { - redirect_uri = { - type = "string" - } - }, - { - scope = { - type = "string", - required = true, - default = "openid" - } - }, - { - validate_scope = { - type = "string", - required = true, - default = "no" - } - }, - { - response_type = { - type = "string", - required = true, - default = "code" - } - }, - { - ssl_verify = { - type = "string", - required = true, - default = "no" - } - }, - { - use_jwks = { - type = "string", - required = true, - default = "no" - } - }, - { - token_endpoint_auth_method = { - type = "string", - required = true, - default = "client_secret_post" - } - }, - { - session_secret = { - type = "string", - required = false - } - }, - { - recovery_page_path = { - type = "string" - } - }, - { - logout_path = { - type = "string", - required = false, - default = "/logout" - } - }, - { - redirect_after_logout_uri = { - type = "string", - required = false, - default = "/" - } - }, - { - redirect_after_logout_with_id_token_hint = { - type = "string", - required = false, - default = "no" - } - }, - { - post_logout_redirect_uri = { - type = "string", - required = false - } - }, - { - unauth_action = { - type = "string", - required = false, - default = "auth" - } - }, - { - filters = { - type = "string" - } - }, - { - ignore_auth_filters = { - type = "string", - required = false - } - }, - { - userinfo_header_name = { - type = "string", - required = false, - default = "X-USERINFO" - } - }, - { - id_token_header_name = { - type = "string", - required = false, - default = "X-ID-Token" - } - }, - { - access_token_header_name = { - type = "string", - required = false, - default = "X-Access-Token" - } - }, - { - access_token_as_bearer = { - type = "string", - required = false, - default = "no" - } - }, - { - disable_userinfo_header = { - type = "string", - required = false, - default = "no" - } - }, - { - disable_id_token_header = { - type = "string", - required = false, - default = "no" - } - }, - { - disable_access_token_header = { - type = "string", - required = false, - default = "no" - } - }, - { - revoke_tokens_on_logout = { - type = "string", - required = false, - default = "no" - } - }, - { - groups_claim = { - type = "string", - required = false, - default = "groups" - } - }, - { - consumer_claim = { - type = "string", - required = false, - default = "sub" - }, - }, - { - consumer_by = { - type = "string", - required = false, - default = "custom_id", - one_of = { "id", "username", "custom_id" }, - }, - }, - { - skip_already_auth_requests = { - type = "string", - required = false, - default = "no" - } - }, - { - bearer_jwt_auth_enable = { - type = "string", - required = false, - default = "no" - } - }, - { - bearer_jwt_auth_allowed_auds = { - type = "array", - required = false, - elements = { - type = "string" - }, - } - }, - { - bearer_jwt_auth_signing_algs = { - type = "array", - required = true, - elements = { - type = "string" - }, - default = { - "RS256" - } - } - }, - { - header_names = { - type = "array", - required = true, - elements = { - type = "string" - }, - default = {} - } - }, - { - header_claims = { - type = "array", - required = true, - elements = { - type = "string" - }, - default = {} - } - }, - { - http_proxy = { - type = "string", - required = false - } - }, - { - https_proxy = { - type = "string", - required = false - } - } - } - } - } - } -} From 5380470587e360e0171e9bdc56c10b388fa3adc1 Mon Sep 17 00:00:00 2001 From: Vahid Tavakkoli Date: Thu, 30 Jul 2026 22:18:48 +0200 Subject: [PATCH 11/25] Remove duplicated local oidc-role session module --- kong/oidc/session.lua | 14 -------------- 1 file changed, 14 deletions(-) delete mode 100644 kong/oidc/session.lua diff --git a/kong/oidc/session.lua b/kong/oidc/session.lua deleted file mode 100644 index 0dcc50b..0000000 --- a/kong/oidc/session.lua +++ /dev/null @@ -1,14 +0,0 @@ -local M = {} - -function M.configure(config) - if config.session_secret then - local decoded_session_secret = ngx.decode_base64(config.session_secret) - if not decoded_session_secret then - kong.log.err("Invalid plugin configuration, session secret could not be decoded") - return kong.response.error(ngx.HTTP_INTERNAL_SERVER_ERROR) - end - ngx.var.session_secret = decoded_session_secret - end -end - -return M From 6e4a5da1391f2db048d2a84c8ebffc11881ede47 Mon Sep 17 00:00:00 2001 From: Vahid Tavakkoli Date: Thu, 30 Jul 2026 22:18:55 +0200 Subject: [PATCH 12/25] Remove duplicated local oidc-role utilities --- kong/oidc/utils.lua | 236 -------------------------------------------- 1 file changed, 236 deletions(-) delete mode 100644 kong/oidc/utils.lua diff --git a/kong/oidc/utils.lua b/kong/oidc/utils.lua deleted file mode 100644 index ca35ebc..0000000 --- a/kong/oidc/utils.lua +++ /dev/null @@ -1,236 +0,0 @@ -local cjson = require("cjson") -local constants = require "kong.constants" - -local M = {} - -local function parseFilters(csvFilters) - local filters = {} - if (not (csvFilters == nil)) and (not (csvFilters == ",")) then - for pattern in string.gmatch(csvFilters, "[^,]+") do - table.insert(filters, pattern) - end - end - return filters -end - -local function formatAsBearerToken(token) - return "Bearer " .. token -end - -function M.get_redirect_uri(ngx) - local function drop_query() - local uri = ngx.var.request_uri - local x = uri:find("?") - if x then - return uri:sub(1, x - 1) - else - return uri - end - end - - local function tackle_slash(path) - local args = ngx.req.get_uri_args() - if args and args.code then - return path - elseif path == "/" then - return "/cb" - elseif path:sub(-1) == "/" then - return path:sub(1, -2) - else - return path .. "/" - end - end - - return tackle_slash(drop_query()) -end - -function M.get_options(config, ngx) - return { - client_id = config.client_id, - client_secret = config.client_secret, - discovery = config.discovery, - introspection_endpoint = config.introspection_endpoint, - introspection_endpoint_auth_method = config.introspection_endpoint_auth_method, - introspection_cache_ignore = config.introspection_cache_ignore, - timeout = config.timeout, - bearer_only = config.bearer_only, - realm = config.realm, - redirect_uri = config.redirect_uri or M.get_redirect_uri(ngx), - scope = config.scope, - validate_scope = config.validate_scope, - response_type = config.response_type, - ssl_verify = config.ssl_verify, - use_jwks = config.use_jwks, - token_endpoint_auth_method = config.token_endpoint_auth_method, - recovery_page_path = config.recovery_page_path, - filters = parseFilters((config.filters or "") .. "," .. (config.ignore_auth_filters or "")), - logout_path = config.logout_path, - revoke_tokens_on_logout = config.revoke_tokens_on_logout == "yes", - redirect_after_logout_uri = config.redirect_after_logout_uri, - redirect_after_logout_with_id_token_hint = config.redirect_after_logout_with_id_token_hint == "yes", - post_logout_redirect_uri = config.post_logout_redirect_uri, - unauth_action = config.unauth_action, - userinfo_header_name = config.userinfo_header_name, - id_token_header_name = config.id_token_header_name, - access_token_header_name = config.access_token_header_name, - access_token_as_bearer = config.access_token_as_bearer == "yes", - disable_userinfo_header = config.disable_userinfo_header == "yes", - disable_id_token_header = config.disable_id_token_header == "yes", - disable_access_token_header = config.disable_access_token_header == "yes", - groups_claim = config.groups_claim, - consumer_claim = config.consumer_claim, - consumer_by = config.consumer_by, - skip_already_auth_requests = config.skip_already_auth_requests == "yes", - bearer_jwt_auth_enable = config.bearer_jwt_auth_enable == "yes", - bearer_jwt_auth_allowed_auds = config.bearer_jwt_auth_allowed_auds, - bearer_jwt_auth_signing_algs = config.bearer_jwt_auth_signing_algs, - header_names = config.header_names or {}, - header_claims = config.header_claims or {}, - proxy_opts = { - http_proxy = config.http_proxy, - https_proxy = config.https_proxy - } - } -end - --- Function set_consumer is derived from the following kong auth plugins: --- https://github.com/Kong/kong/blob/3.0.0/kong/plugins/ldap-auth/access.lua --- https://github.com/Kong/kong/blob/3.0.0/kong/plugins/oauth2/access.lua --- Copyright 2016-2022 Kong Inc. Licensed under the Apache License, Version 2.0 --- https://github.com/Kong/kong/blob/3.0.0/LICENSE -local function set_consumer(consumer, credential) - kong.client.authenticate(consumer, credential) - - local set_header = kong.service.request.set_header - local clear_header = kong.service.request.clear_header - - if consumer and consumer.id then - set_header(constants.HEADERS.CONSUMER_ID, consumer.id) - else - clear_header(constants.HEADERS.CONSUMER_ID) - end - - if consumer and consumer.custom_id then - set_header(constants.HEADERS.CONSUMER_CUSTOM_ID, consumer.custom_id) - else - clear_header(constants.HEADERS.CONSUMER_CUSTOM_ID) - end - - if consumer and consumer.username then - set_header(constants.HEADERS.CONSUMER_USERNAME, consumer.username) - else - clear_header(constants.HEADERS.CONSUMER_USERNAME) - end - - if credential and credential.username then - set_header(constants.HEADERS.CREDENTIAL_IDENTIFIER, credential.username) - else - clear_header(constants.HEADERS.CREDENTIAL_IDENTIFIER) - end - - if credential then - clear_header(constants.HEADERS.ANONYMOUS) - else - set_header(constants.HEADERS.ANONYMOUS, true) - end -end - -function M.injectAccessToken(accessToken, headerName, bearerToken) - ngx.log(ngx.DEBUG, "Injecting " .. headerName) - local token = accessToken - if (bearerToken) then - token = formatAsBearerToken(token) - end - kong.service.request.set_header(headerName, token) -end - -function M.injectIDToken(idToken, headerName) - ngx.log(ngx.DEBUG, "Injecting " .. headerName) - local tokenStr = cjson.encode(idToken) - kong.service.request.set_header(headerName, ngx.encode_base64(tokenStr)) -end - -function M.setCredentials(user) - local tmp_user = user - tmp_user.id = user.sub - tmp_user.username = user.preferred_username - set_consumer(nil, tmp_user) -end - -function M.injectUser(user, headerName) - ngx.log(ngx.DEBUG, "Injecting " .. headerName) - local userinfo = cjson.encode(user) - kong.service.request.set_header(headerName, ngx.encode_base64(userinfo)) -end - -function M.injectGroups(user, claim) - if user[claim] ~= nil then - kong.ctx.shared.authenticated_groups = user[claim] - end -end - -function M.injectHeaders(header_names, header_claims, sources) - if #header_names ~= #header_claims then - kong.log.err('Different number of elements provided in header_names and header_claims. Headers will not be added.') - return - end - kong.log.err(sources) - for i = 1, #header_names do - local header, claim - header = header_names[i] - claim = header_claims[i] - kong.service.request.clear_header(header) - for j = 1, #sources do - local source, claim_value - source = sources[j] - claim_value = source[claim] - kong.log.err(claim_value) - kong.log.err(type(claim_value)) - -- Convert table to string if claim is a table - if type(claim_value) == "table" then - claim_value = table.concat(claim_value, ", ") - end - kong.log.err(claim_value) - if (source and source[claim]) then - kong.service.request.set_header(header, claim_value) - break - end - end - end -end - -function M.has_bearer_access_token() - local header = ngx.req.get_headers()['Authorization'] - if header and header:find(" ") then - local divider = header:find(' ') - if string.lower(header:sub(0, divider-1)) == string.lower("Bearer") then - return true - end - end - return false -end - --- verify if tables t1 and t2 have at least one common string item --- instead of table, also string can be provided as t1 or t2 -function M.has_common_item(t1, t2) - if t1 == nil or t2 == nil then - return false - end - if type(t1) == "string" then - t1 = { t1 } - end - if type(t2) == "string" then - t2 = { t2 } - end - local i1, i2 - for _, i1 in pairs(t1) do - for _, i2 in pairs(t2) do - if type(i1) == "string" and type(i2) == "string" and i1 == i2 then - return true - end - end - end - return false -end - -return M From 972c137a76e4ee023aa9489ba7779dd48f151961 Mon Sep 17 00:00:00 2001 From: Vahid Tavakkoli Date: Thu, 30 Jul 2026 22:19:19 +0200 Subject: [PATCH 13/25] Document external Kong-Role integration --- CHANGELOG.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 751f434..701dd58 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,9 @@ The format is inspired by Keep a Changelog. ### Added +- End-to-end integration test covering real Keycloak tokens, Kong-Role JWT validation, Kong ACL decisions, and all three LOB services. +- GitHub Actions workflow that builds the complete Docker Compose lab and runs the integration matrix. +- Dedicated `gateway/kong.yml` using Kong-Role 2.0.0 authenticated role groups. - Publication-readiness documentation set: - professional README refresh - disclaimer document @@ -21,9 +24,14 @@ The format is inspired by Keep a Changelog. ### Changed +- Kong is now built directly from a pinned commit of `vtavakkoli/Kong-Role` instead of a local plugin copy. +- Authorization now uses all `realm_access.roles` values as Kong authenticated groups; synthetic role consumers are no longer required. +- Enabled Keycloak dynamic backchannel URLs so Kong can retrieve discovery and JWKS data over the internal Compose network. - Repository structure and metadata documentation for clearer onboarding. - Replaced license placeholder with full MIT license text. ### Removed +- Local Kong Dockerfile and duplicated `kong/oidc` plugin implementation. +- Legacy Kong v1 declarative configuration and role-consumer records. - Duplicate per-project `.gitignore` files consolidated into root-level ignore rules. From 80befbf45e2fc6f636d7198699019d330576d743 Mon Sep 17 00:00:00 2001 From: Vahid Tavakkoli Date: Thu, 30 Jul 2026 22:20:32 +0200 Subject: [PATCH 14/25] Trigger integration workflow on branch pushes --- .github/workflows/kong-role-integration.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/kong-role-integration.yml b/.github/workflows/kong-role-integration.yml index a6fa07b..8fd7f23 100644 --- a/.github/workflows/kong-role-integration.yml +++ b/.github/workflows/kong-role-integration.yml @@ -1,6 +1,10 @@ name: kong-role-integration on: + push: + branches: + - main + - "agent/**" pull_request: workflow_dispatch: From 80b7fcac0d7195a81487a945ca0efca47beb98bc Mon Sep 17 00:00:00 2001 From: Vahid Tavakkoli Date: Thu, 30 Jul 2026 22:23:20 +0200 Subject: [PATCH 15/25] Pin IAM lab to fixed Kong-Role container commit --- docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index 4f100c2..add5930 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -55,7 +55,7 @@ services: build: # Kong and oidc-role are built directly from the separate Kong-Role repository. # The commit is pinned so the lab remains reproducible. - context: https://github.com/vtavakkoli/Kong-Role.git#8dde703631b0993b55c1c113d5c6335712f9b27d + context: https://github.com/vtavakkoli/Kong-Role.git#76a11e2faf5571657206ad48d611e786898ea904 image: iam-lab-kong-role:2.0.0 container_name: iam-lab-kong ports: From 3578931382b31206e9753ec4e055dcec00c76010 Mon Sep 17 00:00:00 2001 From: Vahid Tavakkoli Date: Thu, 30 Jul 2026 22:23:59 +0200 Subject: [PATCH 16/25] Document fixed Kong-Role container commit --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index a98629e..41c3f3b 100644 --- a/README.md +++ b/README.md @@ -27,11 +27,11 @@ OpenLDAP ──► Keycloak ──JWT──► Kong-Role ──authenticated gro The local copy of the Kong plugin has been removed. Docker Compose builds the gateway directly from a pinned commit of the separate `Kong-Role` repository: ```text -Kong-Role commit: 8dde703631b0993b55c1c113d5c6335712f9b27d +Kong-Role commit: 76a11e2faf5571657206ad48d611e786898ea904 Plugin version: 2.0.0 ``` -The pin makes the lab reproducible while keeping the gateway implementation maintained in one repository. +This commit includes the reproducible container dependency fix that installs the exact `lua-resty-openidc` library used by the plugin. The pin keeps the lab reproducible while the gateway implementation remains maintained in one repository. ## Repository structure From 36621cfadcbb888ae572afe7ed3da15e7b6cc1c9 Mon Sep 17 00:00:00 2001 From: Vahid Tavakkoli Date: Thu, 30 Jul 2026 22:26:28 +0200 Subject: [PATCH 17/25] Pin IAM integration to vendored OIDC image commit --- docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index add5930..5e36ff3 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -55,7 +55,7 @@ services: build: # Kong and oidc-role are built directly from the separate Kong-Role repository. # The commit is pinned so the lab remains reproducible. - context: https://github.com/vtavakkoli/Kong-Role.git#76a11e2faf5571657206ad48d611e786898ea904 + context: https://github.com/vtavakkoli/Kong-Role.git#62f365509ddebd9ac59586375a0dcebb590d4984 image: iam-lab-kong-role:2.0.0 container_name: iam-lab-kong ports: From 7614eaebcdfd1aa0ef26ac86cb4b1c95731bacbb Mon Sep 17 00:00:00 2001 From: Vahid Tavakkoli Date: Thu, 30 Jul 2026 22:26:59 +0200 Subject: [PATCH 18/25] Update pinned Kong-Role integration commit --- README.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/README.md b/README.md index 41c3f3b..69564f3 100644 --- a/README.md +++ b/README.md @@ -27,11 +27,11 @@ OpenLDAP ──► Keycloak ──JWT──► Kong-Role ──authenticated gro The local copy of the Kong plugin has been removed. Docker Compose builds the gateway directly from a pinned commit of the separate `Kong-Role` repository: ```text -Kong-Role commit: 76a11e2faf5571657206ad48d611e786898ea904 +Kong-Role commit: 62f365509ddebd9ac59586375a0dcebb590d4984 Plugin version: 2.0.0 ``` -This commit includes the reproducible container dependency fix that installs the exact `lua-resty-openidc` library used by the plugin. The pin keeps the lab reproducible while the gateway implementation remains maintained in one repository. +This commit includes a reproducible container dependency fix that vendors the official `lua-resty-openidc` 1.8.0 module without relying on LuaRocks mirror resolution. The pin keeps the lab reproducible while the gateway implementation remains maintained in one repository. ## Repository structure From 23669f1bab92e8d0be4a03805b42aa5718fa4aee Mon Sep 17 00:00:00 2001 From: Vahid Tavakkoli Date: Thu, 30 Jul 2026 22:31:10 +0200 Subject: [PATCH 19/25] Pin IAM integration to complete Kong-Role runtime dependencies --- docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index 5e36ff3..341df85 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -55,7 +55,7 @@ services: build: # Kong and oidc-role are built directly from the separate Kong-Role repository. # The commit is pinned so the lab remains reproducible. - context: https://github.com/vtavakkoli/Kong-Role.git#62f365509ddebd9ac59586375a0dcebb590d4984 + context: https://github.com/vtavakkoli/Kong-Role.git#0e911f258e3f05fca1f7e4a4a4a114fb38a7ac72 image: iam-lab-kong-role:2.0.0 container_name: iam-lab-kong ports: From c44a7befd0042a1dfedea18e7ffbb14fefad04c8 Mon Sep 17 00:00:00 2001 From: Vahid Tavakkoli Date: Thu, 30 Jul 2026 22:39:36 +0200 Subject: [PATCH 20/25] Pin complete Kong-Role JWT runtime --- docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index 341df85..521be04 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -55,7 +55,7 @@ services: build: # Kong and oidc-role are built directly from the separate Kong-Role repository. # The commit is pinned so the lab remains reproducible. - context: https://github.com/vtavakkoli/Kong-Role.git#0e911f258e3f05fca1f7e4a4a4a114fb38a7ac72 + context: https://github.com/vtavakkoli/Kong-Role.git#3ff1670795483dc1a3bbc9e1e58351542634c9c2 image: iam-lab-kong-role:2.0.0 container_name: iam-lab-kong ports: From 24c881bdb9660b37ccd8a1501c50fe24cca57b5e Mon Sep 17 00:00:00 2001 From: Vahid Tavakkoli Date: Thu, 30 Jul 2026 22:47:21 +0200 Subject: [PATCH 21/25] Pin Kong-Role image with HMAC runtime --- docker-compose.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docker-compose.yml b/docker-compose.yml index 521be04..3d17692 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -55,7 +55,7 @@ services: build: # Kong and oidc-role are built directly from the separate Kong-Role repository. # The commit is pinned so the lab remains reproducible. - context: https://github.com/vtavakkoli/Kong-Role.git#3ff1670795483dc1a3bbc9e1e58351542634c9c2 + context: https://github.com/vtavakkoli/Kong-Role.git#f1fdd2e6e9c4b8b58fd0b3b76e67589d18c1abff image: iam-lab-kong-role:2.0.0 container_name: iam-lab-kong ports: From cc157067385ce02cc09346b814411a43648900ac Mon Sep 17 00:00:00 2001 From: Vahid Tavakkoli Date: Thu, 30 Jul 2026 22:56:50 +0200 Subject: [PATCH 22/25] Create representative Keycloak test tokens with subject claims --- tests/integration/test_kong_role.py | 27 ++++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/tests/integration/test_kong_role.py b/tests/integration/test_kong_role.py index e2d6056..7d49e91 100644 --- a/tests/integration/test_kong_role.py +++ b/tests/integration/test_kong_role.py @@ -103,6 +103,10 @@ def ensure_test_client(token: str) -> None: "directAccessGrantsEnabled": True, "serviceAccountsEnabled": False, "fullScopeAllowed": True, + # Match the lab's normal web clients. The profile client scope supplies + # the standard subject/username identity claims expected by Kong-Role. + "defaultClientScopes": ["profile"], + "optionalClientScopes": ["offline_access"], "protocolMappers": [ { "name": "realm-roles", @@ -140,7 +144,7 @@ def user_token(username: str) -> str: "client_id": TEST_CLIENT, "username": username, "password": username, - "scope": "openid", + "scope": "openid profile", }, ) if status != 200 or "access_token" not in payload: @@ -161,13 +165,22 @@ def call_lob(path: str, token: str | None = None) -> tuple[int, str]: def wait_for_gateway(token: str) -> None: deadline = time.time() + TIMEOUT_SECONDS + last_status, last_body = 0, "no response" while time.time() < deadline: - status, _ = call_lob("/lob1", token) - if status == 200: + last_status, last_body = call_lob("/lob1", token) + if last_status == 200: print("[ready] Kong-Role and LOB services are available") return + # Once a valid Keycloak token reaches Kong, these statuses represent a + # deterministic authentication/authorization defect, not startup delay. + if last_status in (401, 403, 500): + raise RuntimeError( + f"Kong-Role rejected a valid readiness token: HTTP {last_status}: {last_body}" + ) time.sleep(2) - raise RuntimeError("Kong-Role gateway did not become ready") + raise RuntimeError( + f"Kong-Role gateway did not become ready: HTTP {last_status}: {last_body}" + ) def assert_status(label: str, actual: int, expected: int, body: str) -> None: @@ -198,8 +211,12 @@ def main() -> int: raise AssertionError( f"{username} token issuer mismatch: {claims.get('iss')} != {PUBLIC_ISSUER}" ) + if not claims.get("sub"): + raise AssertionError(f"{username} token is missing the required sub claim: {claims}") tokens[username] = token - print(f"[pass] {username} token contains {sorted(required_roles)}") + print( + f"[pass] {username} token subject={claims['sub']} roles={sorted(required_roles)}" + ) wait_for_gateway(tokens["alice"]) From 1a630da0944ae82a7f15e8274a4213b96491237c Mon Sep 17 00:00:00 2001 From: Vahid Tavakkoli Date: Thu, 30 Jul 2026 22:58:58 +0200 Subject: [PATCH 23/25] Use Keycloak preferred username as lab principal --- gateway/kong.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/gateway/kong.yml b/gateway/kong.yml index 6c9d6a4..a66d89b 100644 --- a/gateway/kong.yml +++ b/gateway/kong.yml @@ -11,7 +11,9 @@ plugins: allowed_signing_algorithms: - RS256 ssl_verify: false - principal_claim: sub + # Keycloak 26.2 direct-grant access tokens in this lab expose + # preferred_username as the caller identity and omit sub. + principal_claim: preferred_username username_claim: preferred_username authorization_claims: - realm_access.roles From 43e3b5b5e5ef809dfe77ba8a7559ba01f5eaec9f Mon Sep 17 00:00:00 2001 From: Vahid Tavakkoli Date: Thu, 30 Jul 2026 22:59:47 +0200 Subject: [PATCH 24/25] Validate the configured Keycloak principal claim --- tests/integration/test_kong_role.py | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/tests/integration/test_kong_role.py b/tests/integration/test_kong_role.py index 7d49e91..dc1d21e 100644 --- a/tests/integration/test_kong_role.py +++ b/tests/integration/test_kong_role.py @@ -103,8 +103,6 @@ def ensure_test_client(token: str) -> None: "directAccessGrantsEnabled": True, "serviceAccountsEnabled": False, "fullScopeAllowed": True, - # Match the lab's normal web clients. The profile client scope supplies - # the standard subject/username identity claims expected by Kong-Role. "defaultClientScopes": ["profile"], "optionalClientScopes": ["offline_access"], "protocolMappers": [ @@ -171,8 +169,6 @@ def wait_for_gateway(token: str) -> None: if last_status == 200: print("[ready] Kong-Role and LOB services are available") return - # Once a valid Keycloak token reaches Kong, these statuses represent a - # deterministic authentication/authorization defect, not startup delay. if last_status in (401, 403, 500): raise RuntimeError( f"Kong-Role rejected a valid readiness token: HTTP {last_status}: {last_body}" @@ -211,11 +207,13 @@ def main() -> int: raise AssertionError( f"{username} token issuer mismatch: {claims.get('iss')} != {PUBLIC_ISSUER}" ) - if not claims.get("sub"): - raise AssertionError(f"{username} token is missing the required sub claim: {claims}") + if claims.get("preferred_username") != username: + raise AssertionError( + f"{username} token principal mismatch: {claims.get('preferred_username')}" + ) tokens[username] = token print( - f"[pass] {username} token subject={claims['sub']} roles={sorted(required_roles)}" + f"[pass] {username} principal={claims['preferred_username']} roles={sorted(required_roles)}" ) wait_for_gateway(tokens["alice"]) From 8194ba0b85065342c0dd1e74692d3dc9839d592d Mon Sep 17 00:00:00 2001 From: Vahid Tavakkoli Date: Thu, 30 Jul 2026 23:02:17 +0200 Subject: [PATCH 25/25] Document validated Kong-Role integration --- README.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 69564f3..b047fbe 100644 --- a/README.md +++ b/README.md @@ -27,11 +27,11 @@ OpenLDAP ──► Keycloak ──JWT──► Kong-Role ──authenticated gro The local copy of the Kong plugin has been removed. Docker Compose builds the gateway directly from a pinned commit of the separate `Kong-Role` repository: ```text -Kong-Role commit: 62f365509ddebd9ac59586375a0dcebb590d4984 +Kong-Role commit: f1fdd2e6e9c4b8b58fd0b3b76e67589d18c1abff Plugin version: 2.0.0 ``` -This commit includes a reproducible container dependency fix that vendors the official `lua-resty-openidc` 1.8.0 module without relying on LuaRocks mirror resolution. The pin keeps the lab reproducible while the gateway implementation remains maintained in one repository. +This commit includes a reproducible container dependency fix that vendors pinned `lua-resty-openidc`, `lua-resty-jwt`, and `lua-resty-hmac` runtime modules without relying on mutable LuaRocks mirrors. The pin keeps the lab reproducible while the gateway implementation remains maintained in one repository. ## Repository structure @@ -68,14 +68,14 @@ Primary endpoints: - WebApp1: `http://localhost:9101` - WebApp2: `http://localhost:9102` -The sample configuration still uses `10.0.0.50` as the browser-facing host. Change `KC_HOSTNAME`, the web-app public URLs, and redirect URIs in `docker-compose.yml` when your development host uses another address. +The sample configuration uses `10.0.0.50` as the browser-facing host. Change `KC_HOSTNAME`, the web-app public URLs, and redirect URIs in `docker-compose.yml` when your development host uses another address. ## Kong-Role v2 behavior The gateway uses one global `oidc-role` plugin instance to: 1. validate bearer JWT signatures through Keycloak discovery/JWKS; -2. verify the expected issuer and required principal; +2. verify the expected issuer and the configured `preferred_username` principal; 3. extract all values from `realm_access.roles`; 4. publish the roles as Kong authenticated groups; 5. let each route ACL return `200` or `403` based on the required LOB role. @@ -106,7 +106,7 @@ It verifies: - missing token returns `401`; - malformed token returns `401`; -- Keycloak tokens contain the expected issuer and realm roles; +- Keycloak tokens contain the expected issuer, principal, and realm roles; - allowed role/route combinations return `200` and reach the correct LOB service; - denied role/route combinations return `403`; - users with multiple roles retain all authorization groups.