diff --git a/.github/workflows/kong-role-integration.yml b/.github/workflows/kong-role-integration.yml new file mode 100644 index 0000000..8fd7f23 --- /dev/null +++ b/.github/workflows/kong-role-integration.yml @@ -0,0 +1,35 @@ +name: kong-role-integration + +on: + push: + branches: + - main + - "agent/**" + 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 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. diff --git a/README.md b/README.md index 31e1c7a..b047fbe 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: f1fdd2e6e9c4b8b58fd0b3b76e67589d18c1abff +Plugin version: 2.0.0 +``` ---- +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 ```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 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 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. -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, 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. -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). diff --git a/docker-compose.yml b/docker-compose.yml index c6ba3fa..3d17692 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#f1fdd2e6e9c4b8b58fd0b3b76e67589d18c1abff + 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] diff --git a/gateway/kong.yml b/gateway/kong.yml new file mode 100644 index 0000000..a66d89b --- /dev/null +++ b/gateway/kong.yml @@ -0,0 +1,68 @@ +_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 + # 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 + 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 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 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 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 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 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 - } - } - } - } - } - } -} 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 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 diff --git a/tests/integration/test_kong_role.py b/tests/integration/test_kong_role.py new file mode 100644 index 0000000..dc1d21e --- /dev/null +++ b/tests/integration/test_kong_role.py @@ -0,0 +1,258 @@ +#!/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, + "defaultClientScopes": ["profile"], + "optionalClientScopes": ["offline_access"], + "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 profile", + }, + ) + 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 + last_status, last_body = 0, "no response" + while time.time() < deadline: + last_status, last_body = call_lob("/lob1", token) + if last_status == 200: + print("[ready] Kong-Role and LOB services are available") + return + 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( + 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: + 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}" + ) + if claims.get("preferred_username") != username: + raise AssertionError( + f"{username} token principal mismatch: {claims.get('preferred_username')}" + ) + tokens[username] = token + print( + f"[pass] {username} principal={claims['preferred_username']} roles={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)