fix(web-app-jenkins): pin the OIDC scopes to openid profile email

Main run 42235551402 (5d78f3ae7d), job 128223280276 Jenkins#0: the OIDC redirect ended at /securityRealm/finishLogin?error=invalid_scope. With a wellKnown server configuration and no scopesOverride, oic-auth requests every entry of the discovery document's scopes_supported (OicServerWellKnownConfiguration). Keycloak rejects the whole request when one scope is not a default or optional scope of the client. The requested list minus the scopes of the infinito.nexus client leaves exactly kc-admin-api, a Keycloak built-in assigned only to admin-cli and security-admin-console.

casc.yaml.j2 now pins scopesOverride to 'openid profile email'; groups keeps arriving as a default client scope. The new unit test renders casc.yaml.j2 and the shared client template default.json.j2 and asserts every requested scope is one the client carries; without the override both tests fail.

🎯 Fix: 91 %
💥 Break: 7 %
💸 Cost: 100 %
⏱️ Time: 100 %
🛡️ Security: ~100 %
🏆 Score: 85
🚦 Gate: ⛔ break 7 % > 5

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
kevinveenbirkenbachandClaude Opus 5.5 committed 2026-11-07 06:53:47 +00:00
1 parent 5d78f3ae7d
commit 68c9aa5b3a
4 files changed
+108

No files matched your search

@@ -17,6 +17,7 @@ jenkins:
serverConfiguration:
wellKnown:
wellKnownOpenIDConfigurationUrl: "{{ OIDC.CLIENT.DISCOVERY_DOCUMENT }}"
scopesOverride: "openid profile email"
userNameField: "preferred_username"
groupsFieldName: "groups"
logoutFromOpenidProvider: true
Whitespace-only changes.
@@ -0,0 +1,5 @@
from pathlib import Path
PROJECT_ROOT: Path = Path(__file__).resolve().parents[6]
__all__ = ["PROJECT_ROOT"]
@@ -0,0 +1,102 @@
from __future__ import annotations
import json
import re
import unittest
from types import SimpleNamespace
import yaml
from jinja2 import Environment, FileSystemLoader, StrictUndefined, select_autoescape
from . import PROJECT_ROOT
CASC = "roles/web-app-jenkins/templates/casc.yaml.j2"
KEYCLOAK_CLIENT = "roles/web-app-keycloak/templates/import/clients/default.json.j2"
OIDC = {
"CLIENT": {
"ID": "infinito.nexus",
"SECRET": "secret",
"DISCOVERY_DOCUMENT": "https://auth.example.org/realms/infinito.nexus/.well-known/openid-configuration",
}
}
def environment() -> Environment:
env = Environment(
loader=FileSystemLoader(str(PROJECT_ROOT)),
undefined=StrictUndefined,
autoescape=select_autoescape(),
)
env.filters["bool"] = bool
env.filters["to_json"] = json.dumps
env.filters["regex_replace"] = lambda value, pattern, repl: re.sub(
pattern, repl, value
)
return env
def requested_scopes() -> list[str]:
administrator = SimpleNamespace(username="administrator", password="secret")
def lookup(kind, *_args, **_kw):
if kind == "users":
return administrator
if kind == "tls":
return "https://jenkins.example.org/"
raise AssertionError(f"unexpected lookup {kind!r}")
rendered = (
environment()
.get_template(CASC)
.render(
SOFTWARE_NAME="Infinito.Nexus",
JENKINS_OIDC_ENABLED=True,
JENKINS_LDAP_ENABLED=False,
OIDC=OIDC,
application_id="web-app-jenkins",
lookup=lookup,
)
)
well_known = yaml.safe_load(rendered)["jenkins"]["securityRealm"]["oic"][
"serverConfiguration"
]["wellKnown"]
return well_known["scopesOverride"].split()
def client_scopes() -> set[str]:
def lookup(kind, _app, key):
if kind != "config":
raise AssertionError(f"unexpected lookup {kind!r}")
return key.rsplit(".", 1)[-1]
rendered = (
environment()
.get_template(KEYCLOAK_CLIENT)
.render(
KEYCLOAK_CLIENT_ID="infinito.nexus",
KEYCLOAK_REALM_URL="https://auth.example.org/realms/infinito.nexus",
OIDC=OIDC,
KEYCLOAK_REDIRECT_URIS=[],
KEYCLOAK_WEB_ORIGINS=[],
KEYCLOAK_FRONTCHANNEL_LOGOUT_URL="",
KEYCLOAK_POST_LOGOUT_URIS="",
KEYCLOAK_RBAC_GROUP_CLAIM="groups",
KEYCLOAK_MOODLE_ENABLED=False,
application_id="web-app-keycloak",
lookup=lookup,
)
)
client = json.loads(rendered)
return set(client["defaultClientScopes"]) | set(client["optionalClientScopes"])
class TestOidcScopes(unittest.TestCase):
def test_jenkins_asks_for_identity_scopes_only(self) -> None:
self.assertEqual(requested_scopes(), ["openid", "profile", "email"])
def test_every_requested_scope_is_one_the_client_carries(self) -> None:
self.assertLessEqual(set(requested_scopes()), client_scopes())
if __name__ == "__main__":
unittest.main()