diff --git a/tests/lasuite-docs/functional/test_oidc_with_keycloak.py b/tests/lasuite-docs/functional/test_oidc_with_keycloak.py new file mode 100644 index 0000000..2fd5f56 --- /dev/null +++ b/tests/lasuite-docs/functional/test_oidc_with_keycloak.py @@ -0,0 +1,85 @@ +"""lasuite-docs — Q2 SSO-flow acceptance test (Phase 2 §6 Q2 gate). + +The plan §6 Q2 acceptance is: "a dependent recipe can deploy a provider and run an OIDC login test +in one run." This test exercises that contract end-to-end: + +1. The orchestrator deployed a **per-run keycloak** as lasuite-docs's declared dep + (`recipe_meta.DEPS = ["keycloak"]`). Its domain is in `deps_apps["keycloak"]`. +2. Use the shared SSO-setup harness primitive (`runner/harness/sso.py`) to create a realm + OIDC + client + test user in the dep keycloak — idempotent, with cc-ci-controlled identifiers. +3. Exercise the OIDC discovery endpoint (`.well-known/openid-configuration`); assert issuer is + the per-run keycloak's `/realms/`. +4. Perform the **OIDC password grant** against the dep keycloak; assert the returned access_token + is a valid JWT with the expected claims (iss = provider/realm, azp = the OIDC client, exp in + future). This is the canonical "OIDC login" flow — the user-equivalent token issuance — that + proves the SSO subsystem is intact. + +Non-vacuous: a keycloak with broken admin API would fail at setup; a broken token endpoint would +fail at password grant; wrong claims would fail JWT validation. Each step uses real credentials +generated for THIS run (class-B per §4.4-B); destroyed when the dep is torn down at run end. + +This test does NOT yet exercise lasuite-docs's own OIDC-protected endpoints — those would require +wiring the client_secret + OIDC env into lasuite-docs at install time (a future Q3.1 task; see +DECISIONS.md). What it proves NOW is the **dep resolver + SSO-setup harness** end-to-end, which is +exactly the Q2 gate acceptance: a dependent recipe deploys its provider and runs an OIDC test. +""" + +from __future__ import annotations + +import base64 +import json +import os +import sys +import time + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "..", "..", "runner")) +from harness import sso # noqa: E402 + + +def _b64url_decode(seg: str) -> bytes: + pad = "=" * ((4 - len(seg) % 4) % 4) + return base64.urlsafe_b64decode(seg + pad) + + +def test_oidc_password_grant_against_dep_keycloak(live_app, deps_apps): + """End-to-end: keycloak dep is up → set up realm/client/user → OIDC password grant → JWT.""" + assert "keycloak" in deps_apps, ( + f"keycloak dep not deployed; deps_apps={deps_apps}. Q2.3 dep resolver did not run." + ) + kc_domain = deps_apps["keycloak"] + + creds = sso.setup_keycloak_realm( + kc_domain, + realm="lasuite-docs", + client_id="docs", + redirect_uris=[f"https://{live_app}/*"], + web_origins=[f"https://{live_app}"], + ) + # Sanity-check the creds shape + assert creds["provider"] == "keycloak" + assert creds["provider_domain"] == kc_domain + assert creds["realm"] == "lasuite-docs" + assert creds["client_id"] == "docs" + assert isinstance(creds["client_secret"], str) and len(creds["client_secret"]) >= 16 + assert isinstance(creds["password"], str) and len(creds["password"]) >= 16 + + # OIDC discovery endpoint advertises the realm + discovery = sso.assert_discovery_endpoint(creds) + expected_iss = f"https://{kc_domain}/realms/lasuite-docs" + assert discovery.get("issuer") == expected_iss + assert discovery.get("token_endpoint", "").startswith(expected_iss + "/") + assert discovery.get("authorization_endpoint", "").startswith(expected_iss + "/") + + # Password grant flow → real JWT + token = sso.oidc_password_grant(creds) + assert isinstance(token, str) and token.count(".") == 2, ( + f"access_token is not a JWT: {token!r}" + ) + payload = json.loads(_b64url_decode(token.split(".")[1])) + assert payload.get("iss") == expected_iss, f"JWT iss={payload.get('iss')!r} != {expected_iss!r}" + assert payload.get("azp") == "docs", f"JWT azp={payload.get('azp')!r} != 'docs'" + assert payload.get("typ") == "Bearer", f"JWT typ={payload.get('typ')!r} != 'Bearer'" + exp = payload.get("exp") + assert isinstance(exp, int) and exp > time.time(), ( + f"JWT exp={exp!r} not a future timestamp (now={time.time():.0f})" + ) diff --git a/tests/lasuite-docs/recipe_meta.py b/tests/lasuite-docs/recipe_meta.py index a688de3..08ef29b 100644 --- a/tests/lasuite-docs/recipe_meta.py +++ b/tests/lasuite-docs/recipe_meta.py @@ -8,6 +8,12 @@ HEALTH_OK = (200, 301, 302) DEPLOY_TIMEOUT = 900 HTTP_TIMEOUT = 600 +# Phase 2 Q2.3 deps: lasuite-docs's recipe-maintainer corpus declares `requires = ["keycloak"]`. +# Declaring it here makes the orchestrator deploy a per-run keycloak BEFORE lasuite-docs so the +# OIDC-flow functional test (`functional/test_oidc_with_keycloak.py`) can run against a real +# provider in the same run. The dep is undeployed AFTER the parent in the orchestrator's `finally`. +DEPS = ["keycloak"] + def EXTRA_ENV(domain): # abra's internal per-deploy convergence timeout (the recipe's TIMEOUT env, default 300s) is too