Chapter 22Lesson 02~170 minutes

SAML, LDAP, OIDC/SSO, Proxies, TLS, and Network Security: Guided Hands-On Workflow

Build a disposable local HTTPS reverse-proxy lab, validate forwarded headers and server base URL, exercise fake SAML/LDAP/OIDC configuration evidence, and keep a tested local rollback path.

AuthenticationSSOTLSReverse proxyNetwork security

Learning objectives

  • Create a local HTTPS reverse proxy in front of a disposable SonarQube Community Build instance without disabling TLS verification.
  • Prove the external/base URL and forwarded-header contract.
  • Preserve one deliberately broken forwarded-protocol configuration and repair it causally.
  • Validate fake SAML metadata/callback/group mapping without a production IdP.
  • Model LDAP and OIDC configuration boundaries safely without storing real provider credentials.
  • Disconnect/rollback cleanly while preserving a local administrator path.

1. Disposable lab contract

Local-only security lab. Use a disposable SonarQube Community Build instance at http://127.0.0.1:9000. The HTTPS proxy listens only on loopback. The certificate is self-signed specifically for localhost and is trusted explicitly with --cacert; never replace that with -k/--insecure.
State Lab value
SonarQube Community Build 26.9.0.129388
Backend URL http://127.0.0.1:9000
External lab URL https://localhost:9443
Proxy Small Python 3.11+ local-only reverse proxy for teaching; not a production proxy
IdP Fake local metadata/claims only
Secrets No real IdP secret, LDAP password, private enterprise certificate, or scanner token

2. Preflight: record current server and recovery state

mkdir -p sq-ch22-network-lab/evidence
cd sq-ch22-network-lab
python --version
openssl version
curl -fsS http://127.0.0.1:9000/api/system/status \
  | tee evidence/backend-system-status.json

In SonarQube, record the current Server base URL and verify a local administrator login works at the backend URL. Do not disable or delete that account during the lab.

Prediction A. HTTPS through the proxy will work only when the client trusts the generated certificate. Prediction B. Changing the proxy from X-Forwarded-Proto: http to https changes forwarded transport evidence without changing SonarQube, source code, users, or permissions.

3. Create a localhost certificate with a Subject Alternative Name

cat > openssl-local.cnf <<'EOF'
[req]
distinguished_name = dn
x509_extensions = v3
prompt = no
[dn]
CN = localhost
[v3]
subjectAltName = @alt
keyUsage = digitalSignature, keyEncipherment
extendedKeyUsage = serverAuth
basicConstraints = CA:FALSE
[alt]
DNS.1 = localhost
IP.1 = 127.0.0.1
EOF

openssl req -x509 -newkey rsa:3072 -sha256 -nodes \
  -days 2 -keyout localhost.key -out localhost.crt \
  -config openssl-local.cnf

openssl x509 -in localhost.crt -noout -subject -issuer -dates -ext subjectAltName \
  | tee evidence/certificate.txt
chmod 600 localhost.key 2>/dev/null || true

The private key remains local and is deleted during cleanup. The evidence packet contains only non-secret certificate metadata, not the private key.

4. Build a tiny teaching proxy with an intentional header fault

This proxy is deliberately minimal so the learner can see exactly where trust is asserted. It is not a substitute for Nginx, IIS, HAProxy, Caddy, an ingress controller, or a managed load balancer.

# proxy.py — local lab only
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from http.client import HTTPConnection
import ssl, sys

BACKEND = ("127.0.0.1", 9000)
FORWARDED_PROTO = sys.argv[1] if len(sys.argv) > 1 else "http"  # broken first

class Proxy(BaseHTTPRequestHandler):
    def _forward(self):
        body = self.rfile.read(int(self.headers.get("Content-Length", "0")))
        headers = {k: v for k, v in self.headers.items()
                   if k.lower() not in {"host", "connection", "x-forwarded-proto", "x-forwarded-for"}}
        headers["Host"] = "localhost:9443"
        headers["X-Forwarded-Proto"] = FORWARDED_PROTO
        headers["X-Forwarded-For"] = self.client_address[0]
        print(f"FORWARD proto={FORWARDED_PROTO} client={self.client_address[0]} path={self.path}", flush=True)
        conn = HTTPConnection(*BACKEND, timeout=20)
        conn.request(self.command, self.path, body=body, headers=headers)
        resp = conn.getresponse()
        data = resp.read()
        self.send_response(resp.status)
        for k, v in resp.getheaders():
            if k.lower() not in {"transfer-encoding", "connection"}:
                self.send_header(k, v)
        self.end_headers()
        self.wfile.write(data)
        conn.close()
    do_GET = _forward
    do_POST = _forward
    def log_message(self, fmt, *args):
        print("CLIENT " + (fmt % args), flush=True)

server = ThreadingHTTPServer(("127.0.0.1", 9443), Proxy)
ctx = ssl.SSLContext(ssl.PROTOCOL_TLS_SERVER)
ctx.load_cert_chain("localhost.crt", "localhost.key")
server.socket = ctx.wrap_socket(server.socket, server_side=True)
server.serve_forever()
# Broken first run: proxy forwards proto=http even though the client uses HTTPS.
python proxy.py http > evidence/proxy-broken.log 2>&1 &
echo $! > proxy.pid
sleep 1

curl --cacert localhost.crt -fsS \
  https://localhost:9443/api/system/status \
  | tee evidence/proxy-broken-status.json

grep 'FORWARD proto=' evidence/proxy-broken.log

The request may still return HTTP 200 because the backend API itself is reachable; the preserved log proves the proxy is lying about the external transport. That is a configuration defect even before a SAML redirect exposes it.

5. Repair only the forwarded protocol

kill "$(cat proxy.pid)"
python proxy.py https > evidence/proxy-fixed.log 2>&1 &
echo $! > proxy.pid
sleep 1

curl --cacert localhost.crt -fsS \
  https://localhost:9443/api/system/status \
  | tee evidence/proxy-fixed-status.json

grep 'FORWARD proto=' evidence/proxy-fixed.log

Backend SonarQube version, user permissions, database, source code, and project state did not change. Only the proxy’s transport assertion changed. That isolation is the core diagnostic skill.

6. Validate Server base URL without losing rollback

For this disposable lab only, set Server base URL to https://localhost:9443 under Administration → Configuration → General Settings → General. Record the old value first.

before_base_url: <record exact value>
lab_base_url: https://localhost:9443
rollback: restore before_base_url after SSO/proxy validation

Do not set a production base URL to localhost. The goal is to prove that external origin, proxy Host/proto, and SAML callback calculations must agree.

7. Build fake SAML configuration evidence — no external IdP call

<!-- fake-idp-metadata.xml: synthetic evidence only -->
<EntityDescriptor entityID="https://idp.example.invalid/saml">
  <IDPSSODescriptor protocolSupportEnumeration="urn:oasis:names:tc:SAML:2.0:protocol">
    <SingleSignOnService
      Binding="urn:oasis:names:tc:SAML:2.0:bindings:HTTP-Redirect"
      Location="https://idp.example.invalid/sso" />
  </IDPSSODescriptor>
</EntityDescriptor>
{
  "sp_entity_id": "sq-ch22-local",
  "server_base_url": "https://localhost:9443",
  "reply_url": "https://localhost:9443/oauth2/callback/saml",
  "login_attribute": "uid",
  "name_attribute": "displayName",
  "email_attribute": "mail",
  "group_attribute": "groups"
}

Never insert this fake IdP URL into a production instance. The artifact exists to validate the mapping contract and callback math safely.

8. Validate group claims offline before touching authorization

{
  "uid": "alice-lab",
  "displayName": "Alice Lab",
  "mail": "alice@example.invalid",
  "groups": ["sq-ch22-developers"]
}
# validate_claims.py
import json
c = json.load(open("fake-claims.json"))
required = {"uid", "displayName", "groups"}
missing = required - c.keys()
assert not missing, f"missing attributes: {sorted(missing)}"
allowed_groups = {"sq-ch22-developers"}
unexpected = set(c["groups"]) - allowed_groups
assert not unexpected, f"unexpected privileged groups: {sorted(unexpected)}"
print("mapping contract OK")

Create the nonprivileged sq-ch22-developers group locally if you want to validate how a synchronized membership would map to existing SonarQube permissions. Do not map the synthetic claim to sonar-administrators.

9. LDAP path: configuration simulation with an explicit trust note

# ldap-lab.properties — do not apply unless you have a local disposable LDAP server
sonar.security.realm=LDAP
ldap.url=ldaps://ldap.example.invalid:636
ldap.bindDn=cn=sq-reader,ou=svc,dc=example,dc=invalid
ldap.bindPassword=<runtime-secret>
ldap.user.baseDn=ou=people,dc=example,dc=invalid
ldap.group.baseDn=ou=groups,dc=example,dc=invalid

Record: if this were a real LDAPS endpoint, its CA/server certificate would need to be trusted by the SonarQube Java runtime. The repair for a trust failure is to fix the trust chain/hostname—not to disable certificate verification.

10. OIDC path: faithful configuration simulation, not a native-feature claim

{
  "issuer": "https://oidc.example.invalid",
  "authorization_endpoint": "https://oidc.example.invalid/authorize",
  "token_endpoint": "https://oidc.example.invalid/token",
  "jwks_uri": "https://oidc.example.invalid/jwks",
  "redirect_uri_example": "https://localhost:9443/oauth2/callback/oidc",
  "note": "third-party plugin/broker simulation only; recheck plugin compatibility before use"
}

A community OIDC plugin exists, but plugin installation changes server compatibility/security state and requires restart/maintenance governance. It is not part of the mandatory lab. Prefer built-in SAML where it meets the IdP requirement.

11. Disconnect and restore

  1. Restore the original Server base URL.
  2. Stop the local proxy with kill "$(cat proxy.pid)".
  3. Delete localhost.key after preserving only non-secret certificate metadata.
  4. Remove only synthetic groups/users created for this lab.
  5. Verify the local administrator still authenticates directly to the backend/local instance.
  6. Keep proxy logs, base-URL before/after record, certificate metadata, and fake IdP mapping files as evidence.

12. Challenge: which layer owns this failure?

A browser trusts the TLS certificate and reaches the SonarQube login page, but SAML returns “invalid recipient/ACS URL.” The scanner still analyzes successfully with its project token. What should you inspect?

Answer path: preserve browser/proxy/Sonarqube web logs, compare Server base URL with the IdP Reply URL, verify Host and X-Forwarded-Proto=https, and confirm the SAML callback path. Do not rotate the scanner token: scanner authentication is a separate trust path.

Knowledge check

Why does the lab use --cacert localhost.crt instead of -k?

The HTTPS API returns 200 while the proxy log says proto=http. Is the proxy correct?

Why validate group claims offline first?

What should happen to the local recovery administrator during the lab?

What is the safe response to an LDAPS hostname/trust failure?

Next lesson

Choose authentication and topology patterns deliberately

Lesson 3 compares LDAP, SAML, OIDC/plugin, group synchronization, recovery, and topology trade-offs.

Official references and version notes

Version and compatibility note

Rechecked 2026-09-08. Mandatory examples target Community Build 26.9.0.129388. Current Community Build documentation lists HTTP-header, LDAP, SAML, GitHub, Bitbucket Cloud, and GitLab authentication; LDAP and SAML are available in Community Build with JIT/group synchronization. Generic OIDC is not listed as a native method in the current Community Build authentication overview, so this chapter treats it as a configuration simulation or optional third-party-plugin path requiring independent compatibility/security review. SonarQube accepts inbound application traffic as HTTP; production HTTPS is terminated at a reverse proxy/ingress/load balancer. For HTTPS/SAML, X-Forwarded-Proto and X-Forwarded-For must be set by the trusted proxy. Server base URL must match the externally reachable origin/callback design. Automatic provisioning/SCIM and other enterprise identity lifecycle features are edition/provider-specific and are not required by the mandatory local path. Recheck authentication support, plugin compatibility, proxy headers, callback URLs, Java trust-store requirements, and provisioning ownership before applying this material to another release.

Trust-boundary evidence rule. Preserve external/base URL, certificate metadata, proxy configuration and headers, authentication mechanism/version, fake/sandbox IdP mapping, resulting SonarQube group/permission state, local recovery path, and rollback evidence separately. Never place private keys, IdP/LDAP client secrets, session cookies, real user passwords, or scanner tokens in the evidence packet.

Keep the academy open

Support free, practical DevOps education.

Every lesson is designed to remain readable in a browser, downloadable from GitHub, and usable without a paid learning platform. Contributions help expand and maintain the curriculum.

Ethereum / ERC-20
0x716c4Ab160C4B66F31a28AE2448BfF68fc3a2ef0Send only Ethereum/ERC-20 compatible assets to this address.