From 58b8cf57f58ce0d7de315165645bd1efd4e478e6 Mon Sep 17 00:00:00 2001 From: maximthomas Date: Mon, 12 Oct 2026 15:50:55 +0300 Subject: [PATCH 1/4] Kerberos (WindowsDesktopSSO) e2e test without Windows Adds e2e/kerberos: an MIT KDC in a container on the test-openam network, a WindowsDesktopSSO module and a "Kerberos -> DataStore" chain configured with ssoadm, and a Playwright spec that uses a real ticket through the system GSSAPI library (KRB5_CONFIG and KRB5CCNAME point at .work/). Cases: - the browser's Negotiate token completes a login started without it (authId first, then the header): proves the KDC, keytab, module settings and the browser's Kerberos; - one-step REST (curl --negotiate) and the XUI: marked test.fail(), /json/authenticate answers 400 to a Negotiate header (#1170); - no ticket: the chain falls through to the DataStore form. /UI/Login is not covered: XUIFilter redirects it to the XUI. Every value is an environment variable (kerberos.env). The KDC is a provider (providers/.sh: kdc_start, kdc_wait, kdc_export_keytab, kdc_logs, kdc_stop) chosen by KRB_PROVIDER, so a Samba AD DC can be added later without changing the shared code. bootstrap.sh can be run again. The build-docker job installs krb5-user and gettext-base before the first Playwright step. --- .github/workflows/build.yml | 2 + e2e/.gitignore | 3 +- e2e/kerberos/bootstrap.sh | 155 ++++++++++++++ e2e/kerberos/kerberos.env | 39 ++++ e2e/kerberos/kerberos.spec.mjs | 253 +++++++++++++++++++++++ e2e/kerberos/krb5.conf.template | 15 ++ e2e/kerberos/providers/mit.sh | 89 ++++++++ e2e/kerberos/providers/mit/Dockerfile | 26 +++ e2e/kerberos/providers/mit/entrypoint.sh | 59 ++++++ 9 files changed, 640 insertions(+), 1 deletion(-) create mode 100755 e2e/kerberos/bootstrap.sh create mode 100644 e2e/kerberos/kerberos.env create mode 100644 e2e/kerberos/kerberos.spec.mjs create mode 100644 e2e/kerberos/krb5.conf.template create mode 100644 e2e/kerberos/providers/mit.sh create mode 100644 e2e/kerberos/providers/mit/Dockerfile create mode 100644 e2e/kerberos/providers/mit/entrypoint.sh diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 605166b9a3..d1a788bcae 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -310,6 +310,8 @@ jobs: npm init -y npm install @playwright/test npx playwright install chromium --with-deps + # MIT Kerberos client for the Kerberos spec (kinit, klist); libgssapi_krb5 is already on the runner + sudo apt-get update && sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends krb5-user gettext-base npx playwright test --reporter=list - name: Enable HttpOnly session cookie on OpenAM IDP and restart diff --git a/e2e/.gitignore b/e2e/.gitignore index 970bdbd03b..32231f3cf0 100644 --- a/e2e/.gitignore +++ b/e2e/.gitignore @@ -17,4 +17,5 @@ node_modules/ package-lock.json package.json playwright-report/ -test-results/ \ No newline at end of file +test-results/ +kerberos/.work/ diff --git a/e2e/kerberos/bootstrap.sh b/e2e/kerberos/bootstrap.sh new file mode 100755 index 0000000000..d7221d5cb9 --- /dev/null +++ b/e2e/kerberos/bootstrap.sh @@ -0,0 +1,155 @@ +#!/bin/bash + +# The contents of this file are subject to the terms of the Common Development and +# Distribution License (the License). You may not use this file except in compliance with the +# License. +# +# You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the +# specific language governing permission and limitations under the License. +# +# When distributing Covered Software, include this CDDL Header Notice in each file and include +# the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL +# Header, with the fields enclosed by brackets [] replaced by your own identifying +# information: "Portions copyright [year] [name of copyright owner]". +# +# Copyright 2026 3A Systems, LLC. + +# Prepares the Kerberos (WindowsDesktopSSO) e2e test: +# 1. starts a KDC through the provider selected by KRB_PROVIDER (providers/.sh); +# 2. installs the service keytab into the OpenAM container; +# 3. configures the WindowsDesktopSSO module and the chain "module -> DataStore" with ssoadm; +# 4. obtains a TGT for the test user into .work/krb5cc for the browser and curl; +# 5. writes the resolved values to .work/kerberos.json for the spec. +# +# Usage: bootstrap.sh set everything up; safe to run again +# bootstrap.sh logs print the KDC and module logs +# bootstrap.sh stop stop the KDC +# +# Every setting is an environment variable; see kerberos.env and providers/*.sh for defaults. + +set -euo pipefail + +HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +WORK="$HERE/.work" + +# ─── Settings: environment, then common defaults, then the provider, then derived values ── +: "${KRB_PROVIDER:=mit}" +# shellcheck source=kerberos.env +source "$HERE/kerberos.env" + +PROVIDER_FILE="$HERE/providers/$KRB_PROVIDER.sh" +if [[ ! "$KRB_PROVIDER" =~ ^[A-Za-z0-9_-]+$ || ! -f "$PROVIDER_FILE" ]]; then + echo "Unknown KRB_PROVIDER '$KRB_PROVIDER'. Available providers:" >&2 + for provider in "$HERE"/providers/*.sh; do + echo " $(basename "$provider" .sh)" >&2 + done + exit 2 +fi +# shellcheck source=providers/mit.sh +source "$PROVIDER_FILE" + +: "${KRB_SERVICE_PRINCIPAL:=$KRB_SPN@$KRB_REALM}" +: "${KRB_KDC_HOST:=$KRB_KDC_CONTAINER}" + +OPENAM_DEBUG_DIR="/usr/openam/config/openam/debug" + +if [[ "${1:-}" == "logs" ]]; then + echo "===== KDC ($KRB_PROVIDER) =====" + kdc_logs || true + echo "===== OpenAM WindowsDesktopSSO debug log =====" + docker exec "$KRB_OPENAM_CONTAINER" sh -c 'cat "$1"/amAuthWindowsDesktopSSO 2>/dev/null' sh "$OPENAM_DEBUG_DIR" || true + exit 0 +fi +if [[ "${1:-}" == "stop" ]]; then + kdc_stop + exit 0 +fi + +ssoadm() { + local subcommand="$1" + shift + docker exec -w /usr/openam/ssoadmintools/openam/bin "$KRB_OPENAM_CONTAINER" \ + ./ssoadm "$subcommand" --adminid amadmin --password-file pwd.txt "$@" +} + +json_string() { + local value="${1//\\/\\\\}" + printf '"%s"' "${value//\"/\\\"}" +} + +if ! klist -V 2>/dev/null | grep -q '^Kerberos 5 version'; then + echo "MIT Kerberos client tools are required (kinit, klist; Debian/Ubuntu package krb5-user)" >&2 + exit 2 +fi +if ! command -v envsubst >/dev/null; then + echo "envsubst is required (Debian/Ubuntu package gettext-base)" >&2 + exit 2 +fi + +mkdir -p "$WORK" + +echo "Starting KDC (provider $KRB_PROVIDER, realm $KRB_REALM)" +kdc_start +kdc_wait + +echo "Installing the keytab for $KRB_SPN into $KRB_OPENAM_CONTAINER:$KRB_KEYTAB_PATH" +kdc_export_keytab > "$WORK/http.keytab" +docker exec -i "$KRB_OPENAM_CONTAINER" sh -c 'cat > "$1"' sh "$KRB_KEYTAB_PATH" < "$WORK/http.keytab" + +# The JDK accepts only AES keys by default (RC4 and DES are disabled since JDK 17). +keytab_entries="$(klist -ke "$WORK/http.keytab")" +echo "$keytab_entries" +if ! grep -F "$KRB_SPN@$KRB_REALM" <<<"$keytab_entries" | grep -Eq '\(aes(128|256)-cts'; then + echo "The keytab has no AES key for $KRB_SPN@$KRB_REALM" >&2 + exit 1 +fi + +echo "Configuring auth module $KRB_MODULE_NAME and chain $KRB_CHAIN_NAME" +instances="$(ssoadm list-auth-instances --realm /)" +if ! grep -Fxq "$KRB_MODULE_NAME, [type=WindowsDesktopSSO]" <<<"$instances"; then + ssoadm create-auth-instance --realm / --name "$KRB_MODULE_NAME" --authtype WindowsDesktopSSO +fi +ssoadm update-auth-instance --realm / --name "$KRB_MODULE_NAME" --attributevalues \ + "iplanet-am-auth-windowsdesktopsso-principal-name=$KRB_SERVICE_PRINCIPAL" \ + "iplanet-am-auth-windowsdesktopsso-keytab-file=$KRB_KEYTAB_PATH" \ + "iplanet-am-auth-windowsdesktopsso-kerberos-realm=$KRB_REALM" \ + "iplanet-am-auth-windowsdesktopsso-kdc=$KRB_KDC_HOST" \ + "iplanet-am-auth-windowsdesktopsso-returnRealm=false" \ + "iplanet-am-auth-windowsdesktopsso-auth-level=1" + +chains="$(ssoadm list-auth-cfgs --realm /)" +if ! grep -Fxq "$KRB_CHAIN_NAME" <<<"$chains"; then + ssoadm create-auth-cfg --realm / --name "$KRB_CHAIN_NAME" +fi +# Replaces the whole entry list, so a second run leaves the same chain. +ssoadm update-auth-cfg-entr --realm / --name "$KRB_CHAIN_NAME" \ + --entries "$KRB_MODULE_NAME|SUFFICIENT" "DataStore|REQUIRED" + +echo "Obtaining a ticket for $KRB_USER@$KRB_REALM" +KRB_REALM="$KRB_REALM" KRB_KDC_CLIENT_ADDR="$KRB_KDC_CLIENT_ADDR" KRB_SPN_HOST="$KRB_SPN_HOST" \ + envsubst '${KRB_REALM} ${KRB_KDC_CLIENT_ADDR} ${KRB_SPN_HOST}' \ + < "$HERE/krb5.conf.template" > "$WORK/krb5.conf" +export KRB5_CONFIG="$WORK/krb5.conf" +export KRB5CCNAME="FILE:$WORK/krb5cc" +for attempt in 1 2 3 4 5 6 7 8 9 10; do + if printf '%s\n' "$KRB_PASSWORD" | kinit "$KRB_USER@$KRB_REALM" >/dev/null; then + break + fi + if ((attempt == 10)); then + echo "kinit failed" >&2 + exit 1 + fi + sleep 2 +done +klist + +{ + printf '{\n' + printf ' "provider": %s,\n' "$(json_string "$KRB_PROVIDER")" + printf ' "realm": %s,\n' "$(json_string "$KRB_REALM")" + printf ' "user": %s,\n' "$(json_string "$KRB_USER")" + printf ' "module": %s,\n' "$(json_string "$KRB_MODULE_NAME")" + printf ' "chain": %s\n' "$(json_string "$KRB_CHAIN_NAME")" + printf '}\n' +} > "$WORK/kerberos.json" +echo "Kerberos e2e environment is ready" diff --git a/e2e/kerberos/kerberos.env b/e2e/kerberos/kerberos.env new file mode 100644 index 0000000000..f84015eac4 --- /dev/null +++ b/e2e/kerberos/kerberos.env @@ -0,0 +1,39 @@ +# The contents of this file are subject to the terms of the Common Development and +# Distribution License (the License). You may not use this file except in compliance with the +# License. +# +# You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the +# specific language governing permission and limitations under the License. +# +# When distributing Covered Software, include this CDDL Header Notice in each file and include +# the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL +# Header, with the fields enclosed by brackets [] replaced by your own identifying +# information: "Portions copyright [year] [name of copyright owner]". +# +# Copyright 2026 3A Systems, LLC. + +# Defaults of the Kerberos e2e test that do not depend on the KDC provider. +# +# bootstrap.sh sources this file after KRB_PROVIDER is resolved and before +# providers/$KRB_PROVIDER.sh, so provider defaults may refer to these variables. +# A value already set in the environment always wins. + +# Realm of the KDC, the client krb5.conf and the module's Kerberos realm attribute. +: "${KRB_REALM:=EXAMPLE.ORG}" +# Host name of OpenAM as the browser sees it: SPN, [domain_realm] and --auth-server-allowlist. +: "${KRB_SPN_HOST:=openam.example.org}" +# SPN requested by the browser and curl; the service keytab is exported for it. +: "${KRB_SPN:=HTTP/$KRB_SPN_HOST}" +# Keytab location inside the OpenAM container. +: "${KRB_KEYTAB_PATH:=/usr/openam/http.keytab}" +# Address the KDC port is published on, and the KDC address in the client krb5.conf. +# Docker Desktop on macOS cannot publish port 88; use an unprivileged one there, e.g. 127.0.0.1:10088. +: "${KRB_KDC_CLIENT_ADDR:=127.0.0.1:88}" +# Kerberos user; with returnRealm=false it must match an OpenAM user. +: "${KRB_USER:=demo}" +# Names of the WindowsDesktopSSO module instance and of the chain Kerberos -> DataStore. +: "${KRB_MODULE_NAME:=Kerberos}" +: "${KRB_CHAIN_NAME:=KerberosChain}" +# OpenAM container that runs ssoadm and the Docker network it shares with the KDC. +: "${KRB_OPENAM_CONTAINER:=openam-idp}" +: "${KRB_DOCKER_NETWORK:=test-openam}" diff --git a/e2e/kerberos/kerberos.spec.mjs b/e2e/kerberos/kerberos.spec.mjs new file mode 100644 index 0000000000..baa9113528 --- /dev/null +++ b/e2e/kerberos/kerberos.spec.mjs @@ -0,0 +1,253 @@ +/* + * The contents of this file are subject to the terms of the Common Development and + * Distribution License (the License). You may not use this file except in compliance with the + * License. + * + * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the + * specific language governing permission and limitations under the License. + * + * When distributing Covered Software, include this CDDL Header Notice in each file and include + * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL + * Header, with the fields enclosed by brackets [] replaced by your own identifying + * information: "Portions copyright [year] [name of copyright owner]". + * + * Copyright 2026 3A Systems, LLC. + */ + +/** + * OpenAM WindowsDesktopSSO (Kerberos / SPNEGO) e2e test, without Windows. + * + * bootstrap.sh starts a KDC in a container (provider selected by KRB_PROVIDER, MIT by default), + * installs the service keytab into OpenAM, configures the WindowsDesktopSSO module and a chain + * "module SUFFICIENT -> DataStore REQUIRED", and obtains a TGT for the test user into .work/krb5cc. + * The browser and curl use that ticket through the system GSSAPI library (libgssapi_krb5), + * pointed at .work/krb5.conf and .work/krb5cc by KRB5_CONFIG and KRB5CCNAME. + * + * Requires MIT Kerberos client tools (Debian/Ubuntu package krb5-user); skipped without them. + * Runs before HttpOnly mode is enabled: the curl case reads tokenId from the response body. + * + * /UI/Login is not covered: with the XUI enabled (the default) XUIFilter redirects it to the XUI. + */ + +import { test, expect, chromium } from "@playwright/test"; +import { execFileSync } from "child_process"; +import { readFileSync } from "fs"; +import { createServer } from "http"; +import { resolve } from "path"; +import { fileURLToPath } from "url"; +import { OPENAM_BASE, USERNAME, PASSWORD } from "../common/openam-commons.mjs"; + +const __dirname = fileURLToPath(new URL(".", import.meta.url)); + +const BOOTSTRAP_SCRIPT = resolve(__dirname, "bootstrap.sh"); +const WORK_DIR = resolve(__dirname, ".work"); +const OPENAM_HOST = new URL(OPENAM_BASE).hostname; + +// Absolute paths: the browser process does not run in this directory. +const KRB5_CONFIG = resolve(WORK_DIR, "krb5.conf"); +const KRB5CCNAME = `FILE:${resolve(WORK_DIR, "krb5cc")}`; +const KRB_ENV = { ...process.env, KRB5_CONFIG, KRB5CCNAME }; + +const BOOTSTRAP_ENV = { + ...process.env, + KRB_SPN_HOST: process.env.KRB_SPN_HOST ?? OPENAM_HOST, + KRB_USER: process.env.KRB_USER ?? USERNAME, +}; + +// /json/authenticate answers 400 when the Negotiate header arrives with the request that starts the +// login: the module's script TextOutputCallback cannot be filled from the request, so +// RestAuthCallbackHandlerManager converts every callback to JSON and +// RestAuthHttpCallbackHandler.convertToJson rejects the HttpCallback. The browser resends the XUI +// request with the header after the 401 challenge, so the XUI hits the same error. +// Drop test.fail() once the endpoint is fixed. +const REST_NEGOTIATE_BUG = "/json/authenticate rejects a Negotiate header with 400: " + + "https://github.com/OpenIdentityPlatform/OpenAM/issues/1170"; + +const SEL = { + usernameInput: "#idToken1", + passwordInput: "#idToken2", + loginButton: "#loginButton, input[type=\"submit\"], button[type=\"submit\"]", +}; + +// The browser may answer Negotiate challenges from the OpenAM host only. +test.use({ + launchOptions: { + args: [`--auth-server-allowlist=${OPENAM_HOST}`], + env: KRB_ENV, + }, +}); + +/** Values resolved by bootstrap.sh: provider, realm, user, module, chain. */ +let krb; +let kdcStarted = false; + +function hasMitKerberosClient() { + try { + return execFileSync("klist", ["-V"], { encoding: "utf-8", stdio: ["ignore", "pipe", "ignore"] }) + .startsWith("Kerberos 5 version"); + } catch { + return false; + } +} + +function runBootstrap(args = [], options = {}) { + // No shell: the script path is handed to bash as an argument, not interpolated. + return execFileSync("bash", [BOOTSTRAP_SCRIPT, ...args], { env: BOOTSTRAP_ENV, ...options }); +} + +function authenticateUrl(module) { + return `${OPENAM_BASE}/json/authenticate?authIndexType=module&authIndexValue=${encodeURIComponent(module)}`; +} + +/** + * Let the browser answer a Negotiate challenge from a local server on the OpenAM host name and + * return the Authorization header it sent. The port is not part of the SPN, so the token is for the + * OpenAM service principal. + */ +async function negotiateHeaderFromBrowser(page) { + let authorization; + const server = createServer((req, res) => { + if (req.headers.authorization?.startsWith("Negotiate ")) { + authorization = req.headers.authorization; + res.end("ok"); + } else { + res.writeHead(401, { "WWW-Authenticate": "Negotiate" }); + res.end(); + } + }); + await new Promise((listening) => server.listen(0, "127.0.0.1", listening)); + try { + await page.goto(`http://${OPENAM_HOST}:${server.address().port}/`); + } finally { + server.closeAllConnections(); + server.close(); + } + return authorization; +} + +/** Resolve the username of the active session from the session cookie or the given token. */ +async function idFromSession(request, tokenId) { + const headers = { "Accept-API-Version": "protocol=1.0,resource=2.0" }; + if (tokenId) { + headers.iPlanetDirectoryPro = tokenId; + } + const resp = await request.post(`${OPENAM_BASE}/json/users?_action=idFromSession`, { headers }); + if (!resp.ok()) { + return null; + } + return String((await resp.json()).id).toLowerCase(); +} + +test.beforeAll(() => { + // docker build, KDC start, several ssoadm runs and kinit do not fit the default 180 s. + test.setTimeout(600_000); + test.skip(!hasMitKerberosClient(), "MIT Kerberos client tools (kinit, klist) are not installed"); + + console.log(`\n▶ Running bootstrap script: ${BOOTSTRAP_SCRIPT}`); + kdcStarted = true; + try { + runBootstrap([], { stdio: "inherit", timeout: 540_000 }); + } catch (err) { + throw new Error(`${BOOTSTRAP_SCRIPT} exited with code ${err.status}: ${err.message}`); + } + krb = JSON.parse(readFileSync(resolve(WORK_DIR, "kerberos.json"), "utf-8")); +}); + +test.afterAll(() => { + // The KDC must not outlive the spec: CI removes the Docker network it is attached to. + if (kdcStarted) { + runBootstrap(["stop"], { stdio: "inherit", timeout: 60_000 }); + } +}); + +test.afterEach(async ({}, testInfo) => { + if (testInfo.status === testInfo.expectedStatus) { + return; + } + let logs; + try { + logs = runBootstrap(["logs"], { encoding: "utf-8", timeout: 60_000 }); + } catch (err) { + logs = `bootstrap.sh logs failed: ${err.message}`; + } + await testInfo.attach("kerberos-logs", { body: logs, contentType: "text/plain" }); +}); + +test.describe("OpenAM WindowsDesktopSSO - Kerberos SSO", () => { + test("REST: the browser's Negotiate token completes a login started without it", async ({ page }) => { + // The module reads the Negotiate header of whichever request continues the login, so a client + // that first obtains authId and then sends the header together with it is not affected by the + // one-step bug. This proves the KDC, the keytab, the module settings and the browser's Kerberos. + const authorization = await negotiateHeaderFromBrowser(page); + expect(authorization, "the browser should answer the Negotiate challenge").toBeTruthy(); + + const headers = { "Accept-API-Version": "resource=2.0, protocol=1.0" }; + const start = await page.request.post(authenticateUrl(krb.module), { headers, data: {} }); + expect(start.status(), "the login should ask for Negotiate").toBe(401); + const { authId } = await start.json(); + expect(authId, "the challenge should carry authId").toBeTruthy(); + + const finish = await page.request.post(authenticateUrl(krb.module), { + headers: { ...headers, Authorization: authorization }, + data: { authId }, + }); + expect(finish.status(), await finish.text()).toBe(200); + // tokenId is absent in HttpOnly mode; the session cookie set by the response is used then. + const { tokenId } = await finish.json(); + expect(await idFromSession(page.request, tokenId)).toBe(krb.user.toLowerCase()); + }); + + test("REST: curl --negotiate gets a session from /json/authenticate", async ({ request }) => { + test.fail(true, REST_NEGOTIATE_BUG); + + const output = execFileSync("curl", [ + "-sS", "--negotiate", "-u", ":", + "-H", "Content-Type: application/json", + "-H", "Accept-API-Version: resource=2.0, protocol=1.0", + "-d", "{}", + "-w", "\n%{http_code}", + authenticateUrl(krb.module), + ], { encoding: "utf-8", env: KRB_ENV }); + const separator = output.lastIndexOf("\n"); + const body = output.slice(0, separator); + const status = output.slice(separator + 1); + console.log(`/json/authenticate answered ${status}: ${body}`); + + expect(status, "authentication with the Kerberos ticket should succeed").toBe("200"); + const tokenId = JSON.parse(body).tokenId; + expect(tokenId, "the response should carry tokenId").toBeTruthy(); + expect(await idFromSession(request, tokenId)).toBe(krb.user.toLowerCase()); + }); + + test("XUI: the browser logs in through Negotiate without a login form", async ({ page }) => { + test.fail(true, REST_NEGOTIATE_BUG); + + page.on("response", (resp) => { + if (new URL(resp.url()).pathname.endsWith("/authenticate")) { + console.log(`XUI ${resp.request().method()} ${resp.url()} answered ${resp.status()}`); + } + }); + await page.goto(`${OPENAM_BASE}/XUI/#login/&authIndexType=module` + + `&authIndexValue=${encodeURIComponent(krb.module)}`); + await expect.poll(() => idFromSession(page.request), { timeout: 30_000 }).toBe(krb.user.toLowerCase()); + await expect(page.locator(SEL.passwordInput), "no login form should be shown").toHaveCount(0); + }); + + test("fallback: without a ticket the chain continues to the DataStore form", async () => { + // No allowlist and no credential cache: the browser cannot answer the Negotiate challenge, + // the XUI reports http-auth-failed, the Kerberos module is ignored and DataStore asks for a password. + const noCache = `FILE:${resolve(WORK_DIR, "no-such-krb5cc")}`; + const browser = await chromium.launch({ env: { ...KRB_ENV, KRB5CCNAME: noCache } }); + try { + const page = await browser.newPage(); + await page.goto(`${OPENAM_BASE}/XUI/#login/&service=${encodeURIComponent(krb.chain)}`); + await expect(page.locator(SEL.usernameInput)).toBeVisible({ timeout: 30_000 }); + await page.fill(SEL.usernameInput, USERNAME); + await page.fill(SEL.passwordInput, PASSWORD); + await page.locator(SEL.loginButton).first().click(); + await expect.poll(() => idFromSession(page.request), { timeout: 30_000 }).toBe(USERNAME.toLowerCase()); + } finally { + await browser.close(); + } + }); +}); diff --git a/e2e/kerberos/krb5.conf.template b/e2e/kerberos/krb5.conf.template new file mode 100644 index 0000000000..c887ae4e63 --- /dev/null +++ b/e2e/kerberos/krb5.conf.template @@ -0,0 +1,15 @@ +[libdefaults] + default_realm = ${KRB_REALM} + dns_lookup_kdc = false + dns_lookup_realm = false + dns_canonicalize_hostname = false + rdns = false + udp_preference_limit = 1 + +[realms] + ${KRB_REALM} = { + kdc = ${KRB_KDC_CLIENT_ADDR} + } + +[domain_realm] + ${KRB_SPN_HOST} = ${KRB_REALM} diff --git a/e2e/kerberos/providers/mit.sh b/e2e/kerberos/providers/mit.sh new file mode 100644 index 0000000000..dc38d7f5f9 --- /dev/null +++ b/e2e/kerberos/providers/mit.sh @@ -0,0 +1,89 @@ +# The contents of this file are subject to the terms of the Common Development and +# Distribution License (the License). You may not use this file except in compliance with the +# License. +# +# You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the +# specific language governing permission and limitations under the License. +# +# When distributing Covered Software, include this CDDL Header Notice in each file and include +# the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL +# Header, with the fields enclosed by brackets [] replaced by your own identifying +# information: "Portions copyright [year] [name of copyright owner]". +# +# Copyright 2026 3A Systems, LLC. + +# KDC provider "mit": MIT Kerberos KDC in a container built from providers/mit. +# +# A provider sets its defaults and defines the functions bootstrap.sh calls: +# kdc_start build the image and start the KDC with the test user and service principal +# kdc_wait wait until the KDC is up and the service keytab exists +# kdc_export_keytab write the keytab for $KRB_SPN to stdout +# kdc_logs print the KDC logs for diagnostics +# kdc_stop remove the KDC container, so that the Docker network can be removed + +: "${KRB_KDC_IMAGE:=openam-e2e-kdc}" +: "${KRB_KDC_CONTAINER:=openam-kdc}" +: "${KRB_PASSWORD:=changeit}" +: "${KRB_KDC_ADMIN_PASSWORD:=kdc-master-password}" +: "${KRB_KDC_WAIT_SECONDS:=30}" + +MIT_PROVIDER_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/mit" && pwd)" + +kdc_build() { + # --load: with a docker-container buildx builder (as set up in CI) the image would stay in the build cache. + if docker buildx version >/dev/null 2>&1; then + docker buildx build --load -t "$KRB_KDC_IMAGE" "$MIT_PROVIDER_DIR" + else + docker build -t "$KRB_KDC_IMAGE" "$MIT_PROVIDER_DIR" + fi +} + +kdc_start() { + # The build resolves the base image on Docker Hub every time; retry its transient failures. + local attempt + for attempt in 1 2 3; do + if kdc_build; then + break + fi + if ((attempt == 3)); then + return 1 + fi + sleep 5 + done + docker rm -f "$KRB_KDC_CONTAINER" >/dev/null 2>&1 || true + docker run -d --name "$KRB_KDC_CONTAINER" --network "$KRB_DOCKER_NETWORK" \ + -p "$KRB_KDC_CLIENT_ADDR:88/tcp" \ + -e "KRB_REALM=$KRB_REALM" \ + -e "KRB_USER=$KRB_USER" \ + -e "KRB_PASSWORD=$KRB_PASSWORD" \ + -e "KRB_SPN=$KRB_SPN" \ + -e "KRB_KDC_ADMIN_PASSWORD=$KRB_KDC_ADMIN_PASSWORD" \ + "$KRB_KDC_IMAGE" +} + +kdc_wait() { + local deadline=$((SECONDS + KRB_KDC_WAIT_SECONDS)) + until docker exec "$KRB_KDC_CONTAINER" test -s /keytabs/http.keytab 2>/dev/null; do + if [[ "$(docker inspect -f '{{.State.Running}}' "$KRB_KDC_CONTAINER" 2>/dev/null)" != "true" ]]; then + echo "KDC container $KRB_KDC_CONTAINER exited" >&2 + return 1 + fi + if ((SECONDS >= deadline)); then + echo "KDC keytab did not appear in ${KRB_KDC_WAIT_SECONDS}s" >&2 + return 1 + fi + sleep 1 + done +} + +kdc_export_keytab() { + docker exec "$KRB_KDC_CONTAINER" cat /keytabs/http.keytab +} + +kdc_logs() { + docker logs "$KRB_KDC_CONTAINER" 2>&1 +} + +kdc_stop() { + docker rm -f "$KRB_KDC_CONTAINER" >/dev/null 2>&1 || true +} diff --git a/e2e/kerberos/providers/mit/Dockerfile b/e2e/kerberos/providers/mit/Dockerfile new file mode 100644 index 0000000000..5eecfae478 --- /dev/null +++ b/e2e/kerberos/providers/mit/Dockerfile @@ -0,0 +1,26 @@ +# The contents of this file are subject to the terms of the Common Development and +# Distribution License (the License). You may not use this file except in compliance with the +# License. +# +# You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the +# specific language governing permission and limitations under the License. +# +# When distributing Covered Software, include this CDDL Header Notice in each file and include +# the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL +# Header, with the fields enclosed by brackets [] replaced by your own identifying +# information: "Portions copyright [year] [name of copyright owner]". +# +# Copyright 2026 3A Systems, LLC. + +# MIT Kerberos KDC for the Kerberos e2e test. The realm, principals and passwords are not baked +# into the image: entrypoint.sh creates them from the environment on every start. +FROM debian:bookworm-slim + +RUN apt-get update \ + && DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends krb5-kdc krb5-admin-server \ + && rm -rf /var/lib/apt/lists/* + +COPY entrypoint.sh /entrypoint.sh + +EXPOSE 88/tcp +ENTRYPOINT ["/bin/bash", "/entrypoint.sh"] diff --git a/e2e/kerberos/providers/mit/entrypoint.sh b/e2e/kerberos/providers/mit/entrypoint.sh new file mode 100644 index 0000000000..65e5aa755a --- /dev/null +++ b/e2e/kerberos/providers/mit/entrypoint.sh @@ -0,0 +1,59 @@ +#!/bin/bash + +# The contents of this file are subject to the terms of the Common Development and +# Distribution License (the License). You may not use this file except in compliance with the +# License. +# +# You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the +# specific language governing permission and limitations under the License. +# +# When distributing Covered Software, include this CDDL Header Notice in each file and include +# the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL +# Header, with the fields enclosed by brackets [] replaced by your own identifying +# information: "Portions copyright [year] [name of copyright owner]". +# +# Copyright 2026 3A Systems, LLC. + +# Creates the realm, the test user and the service principal, exports the service keytab to +# /keytabs/http.keytab and runs the KDC in the foreground. Logs go to the container output. + +set -euo pipefail + +: "${KRB_REALM:?}" "${KRB_USER:?}" "${KRB_PASSWORD:?}" "${KRB_SPN:?}" "${KRB_KDC_ADMIN_PASSWORD:?}" + +cat > /etc/krb5.conf < /etc/krb5kdc/kdc.conf < /etc/krb5kdc/kadm5.acl + +kdb5_util create -s -r "$KRB_REALM" -P "$KRB_KDC_ADMIN_PASSWORD" +kadmin.local -r "$KRB_REALM" -q "addprinc -pw $KRB_PASSWORD $KRB_USER@$KRB_REALM" +kadmin.local -r "$KRB_REALM" -q "addprinc -randkey $KRB_SPN@$KRB_REALM" +# Export under a temporary name so that a reader never sees a partly written keytab. +kadmin.local -r "$KRB_REALM" -q "ktadd -k /keytabs/http.keytab.tmp $KRB_SPN@$KRB_REALM" +mv /keytabs/http.keytab.tmp /keytabs/http.keytab + +exec krb5kdc -n From 3d3ec5cbe50daf68ca147fb41d58c3b101d37d4f Mon Sep 17 00:00:00 2001 From: maximthomas Date: Mon, 12 Oct 2026 17:21:47 +0300 Subject: [PATCH 2/4] WindowsDesktopSSO module test against an embedded Kerby KDC Adds WindowsDesktopSSOKerberosTest: the module runs its real service login and accepts tokens made by the JDK Kerberos client against an Apache Kerby KDC (kerb-simplekdc 2.1.2, test scope) on a random port. KerberosTestRealm starts the KDC, exports the keytabs and logs the user in; it is a separate class because PowerMock routes the test class's JDK calls through reflection that needs packages the surefire argLine does not open. Cases: - SPNEGO and raw Kerberos V5 tokens log the user in; - returnRealm keeps the realm in the user name; - a token from a trusted realm is accepted; from an untrusted one the module returns LOGIN_IGNORE (the untrustedToken exception thrown in Subject.doAs is not rethrown by process()); - a ticket for another service fails with "auth", an NTLM token with "token". The module's krb5.conf update is a no-op in the test: it splits the KDC attribute on ':' and cannot express Kerby's port. The test installs its own JAAS configuration, because the default one cannot be created once the module sets java.security.auth.login.config to /dev/null on Windows. --- .../openam-auth-windowsdesktopsso/pom.xml | 7 +- .../windowsdesktopsso/KerberosTestRealm.java | 184 ++++++++++++++ .../WindowsDesktopSSOKerberosTest.java | 231 ++++++++++++++++++ pom.xml | 7 + 4 files changed, 428 insertions(+), 1 deletion(-) create mode 100644 openam-authentication/openam-auth-windowsdesktopsso/src/test/java/com/sun/identity/authentication/modules/windowsdesktopsso/KerberosTestRealm.java create mode 100644 openam-authentication/openam-auth-windowsdesktopsso/src/test/java/com/sun/identity/authentication/modules/windowsdesktopsso/WindowsDesktopSSOKerberosTest.java diff --git a/openam-authentication/openam-auth-windowsdesktopsso/pom.xml b/openam-authentication/openam-auth-windowsdesktopsso/pom.xml index 61ad46e6f0..e36f012d52 100755 --- a/openam-authentication/openam-auth-windowsdesktopsso/pom.xml +++ b/openam-authentication/openam-auth-windowsdesktopsso/pom.xml @@ -13,7 +13,7 @@ * information: "Portions copyright [year] [name of copyright owner]". * * Copyright 2011-2016 ForgeRock AS. - * Portions copyright 2025 3A Systems LLC. + * Portions copyright 2025-2026 3A Systems LLC. --> 4.0.0 @@ -60,6 +60,11 @@ powermock-api-mockito2 test + + org.apache.kerby + kerb-simplekdc + test + diff --git a/openam-authentication/openam-auth-windowsdesktopsso/src/test/java/com/sun/identity/authentication/modules/windowsdesktopsso/KerberosTestRealm.java b/openam-authentication/openam-auth-windowsdesktopsso/src/test/java/com/sun/identity/authentication/modules/windowsdesktopsso/KerberosTestRealm.java new file mode 100644 index 0000000000..ee4612c257 --- /dev/null +++ b/openam-authentication/openam-auth-windowsdesktopsso/src/test/java/com/sun/identity/authentication/modules/windowsdesktopsso/KerberosTestRealm.java @@ -0,0 +1,184 @@ +/* + * The contents of this file are subject to the terms of the Common Development and + * Distribution License (the License). You may not use this file except in compliance with the + * License. + * + * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the + * specific language governing permission and limitations under the License. + * + * When distributing Covered Software, include this CDDL Header Notice in each file and include + * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL + * Header, with the fields enclosed by brackets [] replaced by your own identifying + * information: "Portions copyright [year] [name of copyright owner]". + * + * Copyright 2026 3A Systems LLC. + */ + +package com.sun.identity.authentication.modules.windowsdesktopsso; + +import org.apache.commons.io.FileUtils; +import org.apache.kerby.kerberos.kerb.server.SimpleKdcServer; +import org.ietf.jgss.GSSContext; +import org.ietf.jgss.GSSManager; +import org.ietf.jgss.GSSName; +import org.ietf.jgss.Oid; + +import javax.security.auth.Subject; +import javax.security.auth.login.AppConfigurationEntry; +import javax.security.auth.login.Configuration; +import javax.security.auth.login.LoginContext; +import java.io.File; +import java.nio.file.Files; +import java.security.PrivilegedExceptionAction; +import java.util.HashMap; +import java.util.Map; + +/** + * A Kerberos realm for tests: an Apache Kerby KDC on a random local port and a user logged in to it with the JDK + * Kerberos client. + *

+ * Not part of the PowerMock test class: PowerMock routes that class's calls to JDK classes through reflection, + * which fails for JDK packages that the surefire argLine does not open, such as java.nio.file. + *

+ * Changes JVM-wide state while running: {@code java.security.krb5.conf} points at the KDC's krb5.conf, and the + * global JAAS configuration has the client entry. {@link #stop()} restores both. + */ +final class KerberosTestRealm { + + private static final String CLIENT_LOGIN = "kerberos-test-client"; + private static final Oid KERBEROS_PRINCIPAL_NAME = oid("1.2.840.113554.1.2.2.1"); + // Set by Kerby and by the module's service login. + private static final String[] SYSTEM_PROPERTIES = {"java.security.krb5.conf", "java.security.krb5.realm", + "java.security.krb5.kdc", "java.security.auth.login.config"}; + + private final Map savedProperties = new HashMap<>(); + private File workDir; + private SimpleKdcServer kdc; + private Subject client; + + /** + * Starts the KDC with the given principals, all with random keys, and logs the user in. + * + * @param realm the Kerberos realm. + * @param user the principal of the client user. + * @param services service principals to create. + * @throws Exception if the KDC does not start or the user cannot log in. + */ + void start(String realm, String user, String... services) throws Exception { + for (String name : SYSTEM_PROPERTIES) { + savedProperties.put(name, System.getProperty(name)); + } + workDir = Files.createTempDirectory("kerberos-test-realm").toFile(); + kdc = new SimpleKdcServer(); + kdc.setWorkDir(workDir); + kdc.setKdcRealm(realm); + kdc.setAllowUdp(false); + // Writes workDir/krb5.conf with "kdc = localhost:" and sets java.security.krb5.conf to it. + kdc.init(); + kdc.start(); + kdc.createPrincipal(user); + for (String service : services) { + kdc.createPrincipal(service); + } + + // The default JAAS configuration cannot be created once java.security.auth.login.config names a missing + // file, as the "/dev/null" set by the module does on Windows. A configuration set here is used instead. + Configuration.setConfiguration(new ClientConfiguration(user, exportKeytab(user))); + LoginContext login = new LoginContext(CLIENT_LOGIN); + login.login(); + client = login.getSubject(); + } + + /** + * Exports the keys of a principal. + * + * @param principal the principal. + * @return the keytab file. + * @throws Exception if the keytab cannot be written. + */ + File exportKeytab(String principal) throws Exception { + File keytab = new File(workDir, principal.replaceAll("\\W", "_") + ".keytab"); + kdc.exportPrincipal(principal, keytab); + return keytab; + } + + /** + * Returns the first token the user sends to a service, as a browser does after "WWW-Authenticate: Negotiate". + * + * @param servicePrincipal the service principal, with the realm. + * @param mech the GSS mechanism OID: SPNEGO or Kerberos V5. + * @return the token. + * @throws Exception if the KDC does not issue a ticket for the service. + */ + byte[] initialToken(String servicePrincipal, String mech) throws Exception { + return Subject.doAs(client, (PrivilegedExceptionAction) () -> { + GSSManager manager = GSSManager.getInstance(); + GSSName service = manager.createName(servicePrincipal, KERBEROS_PRINCIPAL_NAME); + GSSContext context = manager.createContext(service, oid(mech), null, GSSContext.DEFAULT_LIFETIME); + try { + return context.initSecContext(new byte[0], 0, 0); + } finally { + context.dispose(); + } + }); + } + + /** + * Stops the KDC and restores the JAAS configuration and the system properties. + * + * @throws Exception if the KDC does not stop. + */ + void stop() throws Exception { + try { + if (kdc != null) { + kdc.stop(); + } + } finally { + Configuration.setConfiguration(null); + for (Map.Entry property : savedProperties.entrySet()) { + if (property.getValue() == null) { + System.clearProperty(property.getKey()); + } else { + System.setProperty(property.getKey(), property.getValue()); + } + } + FileUtils.deleteQuietly(workDir); + } + } + + private static Oid oid(String value) { + try { + return new Oid(value); + } catch (Exception e) { + throw new IllegalArgumentException(value, e); + } + } + + /** JAAS entry of the client: the user's TGT, obtained with the user's keytab. */ + private static final class ClientConfiguration extends Configuration { + private final String user; + private final File keytab; + + ClientConfiguration(String user, File keytab) { + this.user = user; + this.keytab = keytab; + } + + @Override + public AppConfigurationEntry[] getAppConfigurationEntry(String name) { + if (!CLIENT_LOGIN.equals(name)) { + return null; + } + Map options = new HashMap<>(); + options.put("principal", user); + options.put("useKeyTab", "true"); + options.put("keyTab", keytab.getAbsolutePath()); + options.put("doNotPrompt", "true"); + // The JDK reads krb5.conf once per JVM; this one appeared after the JVM started. + options.put("refreshKrb5Config", "true"); + return new AppConfigurationEntry[] {new AppConfigurationEntry( + "com.sun.security.auth.module.Krb5LoginModule", + AppConfigurationEntry.LoginModuleControlFlag.REQUIRED, options)}; + } + } +} diff --git a/openam-authentication/openam-auth-windowsdesktopsso/src/test/java/com/sun/identity/authentication/modules/windowsdesktopsso/WindowsDesktopSSOKerberosTest.java b/openam-authentication/openam-auth-windowsdesktopsso/src/test/java/com/sun/identity/authentication/modules/windowsdesktopsso/WindowsDesktopSSOKerberosTest.java new file mode 100644 index 0000000000..73fffd5684 --- /dev/null +++ b/openam-authentication/openam-auth-windowsdesktopsso/src/test/java/com/sun/identity/authentication/modules/windowsdesktopsso/WindowsDesktopSSOKerberosTest.java @@ -0,0 +1,231 @@ +/* + * The contents of this file are subject to the terms of the Common Development and + * Distribution License (the License). You may not use this file except in compliance with the + * License. + * + * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the + * specific language governing permission and limitations under the License. + * + * When distributing Covered Software, include this CDDL Header Notice in each file and include + * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL + * Header, with the fields enclosed by brackets [] replaced by your own identifying + * information: "Portions copyright [year] [name of copyright owner]". + * + * Copyright 2026 3A Systems LLC. + */ + +package com.sun.identity.authentication.modules.windowsdesktopsso; + +import com.iplanet.am.util.SystemProperties; +import com.sun.identity.authentication.service.AuthD; +import com.sun.identity.authentication.spi.AuthLoginException; +import com.sun.identity.authentication.util.ISAuthConstants; +import jakarta.servlet.http.HttpServletRequest; +import org.forgerock.guice.core.InjectorHolder; +import org.powermock.api.mockito.PowerMockito; +import org.powermock.core.classloader.annotations.PowerMockIgnore; +import org.powermock.core.classloader.annotations.PrepareForTest; +import org.powermock.modules.testng.PowerMockTestCase; +import org.testng.annotations.AfterClass; +import org.testng.annotations.BeforeClass; +import org.testng.annotations.BeforeMethod; +import org.testng.annotations.Test; + +import java.io.File; +import java.util.Base64; +import java.util.Collections; +import java.util.HashMap; +import java.util.Map; + +import static org.mockito.ArgumentMatchers.anyBoolean; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; +import static org.testng.Assert.assertEquals; +import static org.testng.Assert.assertNull; +import static org.testng.Assert.expectThrows; + +/** + * Runs {@link WindowsDesktopSSO} against a real KDC (Apache Kerby) with tokens made by the JDK Kerberos client. + *

+ * The module uses the krb5.conf that Kerby writes and points {@code java.security.krb5.conf} at: + * {@link WindowsDesktopSSO#createUpdateKrb5ConfigFile()} is a no-op here, because the module splits its KDC + * attribute on ':' and cannot express the random port Kerby listens on. + */ +@PrepareForTest({SystemProperties.class, AuthD.class, InjectorHolder.class}) +// JAAS, JGSS, JCE and the JDK Krb5LoginModule must come from the system class loader: their copies in the +// PowerMock class loader fail with ClassCastException, IllegalAccessError, VerifyError or no AES cipher. +@PowerMockIgnore({"javax.security.*", "javax.crypto.*", "org.ietf.jgss.*", "com.sun.security.*", + "jdk.internal.reflect.*"}) +public class WindowsDesktopSSOKerberosTest extends PowerMockTestCase { + + private static final String REALM = "EXAMPLE.COM"; + private static final String SERVICE_PRINCIPAL = "HTTP/localhost@" + REALM; + private static final String OTHER_SERVICE_PRINCIPAL = "HTTP/other@" + REALM; + private static final String USER = "demo"; + private static final String USER_PRINCIPAL = USER + "@" + REALM; + private static final int AUTH_LEVEL = 5; + + private static final String SPNEGO_MECH = "1.3.6.1.5.5.2"; + private static final String KERBEROS_MECH = "1.2.840.113554.1.2.2"; + // NTLM negotiate message, as Windows sends it when it has no Kerberos ticket. + private static final String NTLM_TOKEN = "TlRMTVNTUAABAAAAB4IIogAAAAAAAAAAAAAAAAAAAAAKAGFKAAAADw=="; + + private static final String ATTR_PREFIX = "iplanet-am-auth-windowsdesktopsso-"; + + private final KerberosTestRealm realm = new KerberosTestRealm(); + private File serviceKeytab; + + @BeforeClass + public void startKdc() throws Exception { + realm.start(REALM, USER_PRINCIPAL, SERVICE_PRINCIPAL, OTHER_SERVICE_PRINCIPAL); + serviceKeytab = realm.exportKeytab(SERVICE_PRINCIPAL); + } + + @AfterClass(alwaysRun = true) + public void stopKdc() throws Exception { + realm.stop(); + } + + @BeforeMethod + public void mockStatics() { + // PowerMockTestCase clears the static mocks after every test method. + PowerMockito.mockStatic(SystemProperties.class, InjectorHolder.class, AuthD.class); + // Nothing configured: the krb5.conf mode of the module and the JDK Krb5LoginModule. + PowerMockito.when(SystemProperties.get(anyString(), anyString())) + .thenAnswer(invocation -> invocation.getArgument(1)); + PowerMockito.when(SystemProperties.getAsBoolean(anyString(), anyBoolean())) + .thenAnswer(invocation -> invocation.getArgument(1)); + } + + @Test + public void spnegoTokenLogsUserIn() throws Exception { + TestModule module = newModule("spnego", negotiate(SERVICE_PRINCIPAL, SPNEGO_MECH), options()); + + assertEquals(module.process(null, ISAuthConstants.LOGIN_START), ISAuthConstants.LOGIN_SUCCEED); + assertEquals(module.getPrincipal().getName(), USER); + assertEquals(module.moduleAuthLevel, AUTH_LEVEL); + } + + @Test + public void kerberosTokenLogsUserIn() throws Exception { + TestModule module = newModule("kerberos", negotiate(SERVICE_PRINCIPAL, KERBEROS_MECH), options()); + + assertEquals(module.process(null, ISAuthConstants.LOGIN_START), ISAuthConstants.LOGIN_SUCCEED); + assertEquals(module.getPrincipal().getName(), USER); + } + + @Test + public void returnRealmKeepsRealmInUserName() throws Exception { + Map options = options(); + options.put(ATTR_PREFIX + "returnRealm", Collections.singleton("true")); + TestModule module = newModule("returnRealm", negotiate(SERVICE_PRINCIPAL, SPNEGO_MECH), options); + + assertEquals(module.process(null, ISAuthConstants.LOGIN_START), ISAuthConstants.LOGIN_SUCCEED); + assertEquals(module.getPrincipal().getName(), USER_PRINCIPAL); + } + + @Test + public void tokenFromTrustedRealmLogsUserIn() throws Exception { + Map options = options(); + options.put(ATTR_PREFIX + "kerberos-realms-trusted", Collections.singleton(REALM)); + TestModule module = newModule("trustedRealm", negotiate(SERVICE_PRINCIPAL, SPNEGO_MECH), options); + + assertEquals(module.process(null, ISAuthConstants.LOGIN_START), ISAuthConstants.LOGIN_SUCCEED); + assertEquals(module.getPrincipal().getName(), USER); + } + + @Test + public void tokenFromUntrustedRealmDoesNotLogUserIn() throws Exception { + Map options = options(); + options.put(ATTR_PREFIX + "kerberos-realms-trusted", Collections.singleton("OTHER.COM")); + TestModule module = newModule("untrustedRealm", negotiate(SERVICE_PRINCIPAL, SPNEGO_MECH), options); + + // The "untrustedToken" AuthLoginException is thrown inside Subject.doAs, and process() rethrows only a + // wrapped GSSException, so the module ignores the token instead of failing. + assertEquals(module.process(null, ISAuthConstants.LOGIN_START), ISAuthConstants.LOGIN_IGNORE); + assertNull(module.getPrincipal()); + } + + @Test + public void tokenForAnotherServiceIsRejected() throws Exception { + TestModule module = newModule("otherService", negotiate(OTHER_SERVICE_PRINCIPAL, SPNEGO_MECH), options()); + + AuthLoginException e = expectThrows(AuthLoginException.class, + () -> module.process(null, ISAuthConstants.LOGIN_START)); + assertEquals(e.getErrorCode(), "auth"); + assertNull(module.getPrincipal()); + } + + @Test + public void ntlmTokenIsRejected() throws Exception { + TestModule module = newModule("ntlm", "Negotiate " + NTLM_TOKEN, options()); + + AuthLoginException e = expectThrows(AuthLoginException.class, + () -> module.process(null, ISAuthConstants.LOGIN_START)); + assertEquals(e.getErrorCode(), "token"); + assertNull(module.getPrincipal()); + } + + /** Module settings as stored in the auth module instance; each test changes what it is about. */ + private Map options() { + Map options = new HashMap<>(); + options.put(ATTR_PREFIX + "principal-name", Collections.singleton(SERVICE_PRINCIPAL)); + options.put(ATTR_PREFIX + "keytab-file", Collections.singleton(serviceKeytab.getAbsolutePath())); + options.put(ATTR_PREFIX + "kerberos-realm", Collections.singleton(REALM)); + // Required by the module, but not used: the KDC address comes from Kerby's krb5.conf. + options.put(ATTR_PREFIX + "kdc", Collections.singleton("localhost")); + options.put(ATTR_PREFIX + "auth-level", Collections.singleton(String.valueOf(AUTH_LEVEL))); + return options; + } + + /** + * Creates the module for a request with the given Authorization header. Every test uses its own instance name, + * because the module caches the service login per instance in a static table. + */ + private TestModule newModule(String instanceName, String authorization, Map options) { + HttpServletRequest request = mock(HttpServletRequest.class); + when(request.getHeader("Authorization")).thenReturn(authorization); + options.put(ISAuthConstants.MODULE_INSTANCE_NAME, instanceName); + TestModule module = new TestModule(request); + // The module does not use the subject. + module.init(null, new HashMap<>(), options); + return module; + } + + /** The Authorization header of the user's first request to the service. */ + private String negotiate(String servicePrincipal, String mech) throws Exception { + return "Negotiate " + Base64.getEncoder().encodeToString(realm.initialToken(servicePrincipal, mech)); + } + + /** The module with the request and realm that the authentication service would provide. */ + private static final class TestModule extends WindowsDesktopSSO { + private final HttpServletRequest request; + private int moduleAuthLevel; + + TestModule(HttpServletRequest request) { + this.request = request; + } + + @Override + public HttpServletRequest getHttpServletRequest() { + return request; + } + + @Override + public String getRequestOrg() { + return "/"; + } + + @Override + public boolean setAuthLevel(int authLevel) { + moduleAuthLevel = authLevel; + return true; + } + + @Override + protected void createUpdateKrb5ConfigFile() { + // Keep Kerby's krb5.conf: the module would replace "kdc = localhost:" with two KDCs. + } + } +} diff --git a/pom.xml b/pom.xml index 9c5dd0d481..07ef4ef81d 100644 --- a/pom.xml +++ b/pom.xml @@ -105,6 +105,7 @@ 1.1.1 2.0.0 2.23.4 + 2.1.2 1.1.5.2 20020414 2.0.1 @@ -1605,6 +1606,12 @@ ${mockito.version} test + + org.apache.kerby + kerb-simplekdc + ${kerby.version} + test + org.mortbay.jetty jetty-servlet-tester From 56d1c9e5706619cd9397ecd607207f7c140823ed Mon Sep 17 00:00:00 2001 From: maximthomas Date: Mon, 12 Oct 2026 19:10:07 +0300 Subject: [PATCH 3/4] Kerberos e2e: assert the #1170 400 and the chain's Negotiate challenge The curl and XUI cases assert the 400 and the HttpCallback message from #1170 instead of test.fail(), which accepted any failure. curl without GSS-API, no token sent or a broken XUI fragment now turn them red, and so does the fix of #1170. The fallback case first checks that the chain answers 401 with WWW-Authenticate: Negotiate, so it fails when the Kerberos module is missing from the chain. A failed bootstrap prints the KDC and module logs before rethrowing: Playwright skips afterEach after a failed beforeAll, and afterAll removes the KDC container. Also corrects the comment on the no-op createUpdateKrb5ConfigFile() in WindowsDesktopSSOKerberosTest: the module would write one kdc=localhost (port 88), not two KDCs. --- e2e/kerberos/kerberos.spec.mjs | 52 +++++++++++++------ .../WindowsDesktopSSOKerberosTest.java | 2 +- 2 files changed, 36 insertions(+), 18 deletions(-) diff --git a/e2e/kerberos/kerberos.spec.mjs b/e2e/kerberos/kerberos.spec.mjs index baa9113528..351eeb9088 100644 --- a/e2e/kerberos/kerberos.spec.mjs +++ b/e2e/kerberos/kerberos.spec.mjs @@ -24,7 +24,7 @@ * pointed at .work/krb5.conf and .work/krb5cc by KRB5_CONFIG and KRB5CCNAME. * * Requires MIT Kerberos client tools (Debian/Ubuntu package krb5-user); skipped without them. - * Runs before HttpOnly mode is enabled: the curl case reads tokenId from the response body. + * Runs before HttpOnly mode is enabled: once #1170 is fixed, the curl case reads tokenId from the response body. * * /UI/Login is not covered: with the XUI enabled (the default) XUIFilter redirects it to the XUI. */ @@ -59,9 +59,12 @@ const BOOTSTRAP_ENV = { // RestAuthCallbackHandlerManager converts every callback to JSON and // RestAuthHttpCallbackHandler.convertToJson rejects the HttpCallback. The browser resends the XUI // request with the header after the 401 challenge, so the XUI hits the same error. -// Drop test.fail() once the endpoint is fixed. +// The curl and XUI cases assert that 400. Once the endpoint is fixed they turn red: assert the session instead. const REST_NEGOTIATE_BUG = "/json/authenticate rejects a Negotiate header with 400: " + "https://github.com/OpenIdentityPlatform/OpenAM/issues/1170"; +// A request without the Authorization header gets the module's 401 first, so only one with the header +// reaches this message. +const HTTP_CALLBACK_REJECTED = "HttpCallback Authorization Header must be specified in the initial request"; const SEL = { usernameInput: "#idToken1", @@ -95,8 +98,8 @@ function runBootstrap(args = [], options = {}) { return execFileSync("bash", [BOOTSTRAP_SCRIPT, ...args], { env: BOOTSTRAP_ENV, ...options }); } -function authenticateUrl(module) { - return `${OPENAM_BASE}/json/authenticate?authIndexType=module&authIndexValue=${encodeURIComponent(module)}`; +function authenticateUrl(indexValue, indexType = "module") { + return `${OPENAM_BASE}/json/authenticate?authIndexType=${indexType}&authIndexValue=${encodeURIComponent(indexValue)}`; } /** @@ -148,6 +151,12 @@ test.beforeAll(() => { try { runBootstrap([], { stdio: "inherit", timeout: 540_000 }); } catch (err) { + // A failed beforeAll skips afterEach, and afterAll removes the KDC container: print its logs now. + try { + console.log(runBootstrap(["logs"], { encoding: "utf-8", timeout: 60_000 })); + } catch (logsErr) { + console.log(`bootstrap.sh logs failed: ${logsErr.message}`); + } throw new Error(`${BOOTSTRAP_SCRIPT} exited with code ${err.status}: ${err.message}`); } krb = JSON.parse(readFileSync(resolve(WORK_DIR, "kerberos.json"), "utf-8")); @@ -197,9 +206,7 @@ test.describe("OpenAM WindowsDesktopSSO - Kerberos SSO", () => { expect(await idFromSession(page.request, tokenId)).toBe(krb.user.toLowerCase()); }); - test("REST: curl --negotiate gets a session from /json/authenticate", async ({ request }) => { - test.fail(true, REST_NEGOTIATE_BUG); - + test("REST: curl --negotiate is rejected with 400 until #1170 is fixed", async () => { const output = execFileSync("curl", [ "-sS", "--negotiate", "-u", ":", "-H", "Content-Type: application/json", @@ -213,27 +220,38 @@ test.describe("OpenAM WindowsDesktopSSO - Kerberos SSO", () => { const status = output.slice(separator + 1); console.log(`/json/authenticate answered ${status}: ${body}`); - expect(status, "authentication with the Kerberos ticket should succeed").toBe("200"); - const tokenId = JSON.parse(body).tokenId; - expect(tokenId, "the response should carry tokenId").toBeTruthy(); - expect(await idFromSession(request, tokenId)).toBe(krb.user.toLowerCase()); + // #1170: when this turns red, assert 200, tokenId and the session from idFromSession instead. + expect(status, REST_NEGOTIATE_BUG).toBe("400"); + expect(body, "the 400 should come from curl's Negotiate header").toContain(HTTP_CALLBACK_REJECTED); }); - test("XUI: the browser logs in through Negotiate without a login form", async ({ page }) => { - test.fail(true, REST_NEGOTIATE_BUG); - + test("XUI: the Negotiate login is rejected with 400 until #1170 is fixed", async ({ page }) => { page.on("response", (resp) => { if (new URL(resp.url()).pathname.endsWith("/authenticate")) { console.log(`XUI ${resp.request().method()} ${resp.url()} answered ${resp.status()}`); } }); + // The browser answers the 401 challenge in its network stack: the page sees only the resent request. + const login = page.waitForResponse((resp) => resp.request().method() === "POST" + && new URL(resp.url()).pathname.endsWith("/authenticate")); await page.goto(`${OPENAM_BASE}/XUI/#login/&authIndexType=module` + `&authIndexValue=${encodeURIComponent(krb.module)}`); - await expect.poll(() => idFromSession(page.request), { timeout: 30_000 }).toBe(krb.user.toLowerCase()); - await expect(page.locator(SEL.passwordInput), "no login form should be shown").toHaveCount(0); + const resp = await login; + // #1170: when this turns red, poll idFromSession for the user and assert that no login form is shown instead. + expect(resp.status(), REST_NEGOTIATE_BUG).toBe(400); + expect(await resp.text(), "the 400 should come from the browser's Negotiate header") + .toContain(HTTP_CALLBACK_REJECTED); }); - test("fallback: without a ticket the chain continues to the DataStore form", async () => { + test("fallback: without a ticket the chain continues to the DataStore form", async ({ request }) => { + // The chain must start with the Kerberos module's challenge, or the DataStore form below proves nothing. + const start = await request.post(authenticateUrl(krb.chain, "service"), { + headers: { "Accept-API-Version": "resource=2.0, protocol=1.0" }, + data: {}, + }); + expect(start.status(), "the chain should start with the Negotiate challenge").toBe(401); + expect(start.headers()["www-authenticate"]).toBe("Negotiate"); + // No allowlist and no credential cache: the browser cannot answer the Negotiate challenge, // the XUI reports http-auth-failed, the Kerberos module is ignored and DataStore asks for a password. const noCache = `FILE:${resolve(WORK_DIR, "no-such-krb5cc")}`; diff --git a/openam-authentication/openam-auth-windowsdesktopsso/src/test/java/com/sun/identity/authentication/modules/windowsdesktopsso/WindowsDesktopSSOKerberosTest.java b/openam-authentication/openam-auth-windowsdesktopsso/src/test/java/com/sun/identity/authentication/modules/windowsdesktopsso/WindowsDesktopSSOKerberosTest.java index 73fffd5684..f8e2118d8e 100644 --- a/openam-authentication/openam-auth-windowsdesktopsso/src/test/java/com/sun/identity/authentication/modules/windowsdesktopsso/WindowsDesktopSSOKerberosTest.java +++ b/openam-authentication/openam-auth-windowsdesktopsso/src/test/java/com/sun/identity/authentication/modules/windowsdesktopsso/WindowsDesktopSSOKerberosTest.java @@ -225,7 +225,7 @@ public boolean setAuthLevel(int authLevel) { @Override protected void createUpdateKrb5ConfigFile() { - // Keep Kerby's krb5.conf: the module would replace "kdc = localhost:" with two KDCs. + // Keep Kerby's krb5.conf: the module would rewrite "kdc = localhost:" as "kdc=localhost", port 88. } } } From 59ffaa81f63d7cbb319bd7367e24de7460dfda0d Mon Sep 17 00:00:00 2001 From: maximthomas Date: Tue, 13 Oct 2026 09:44:21 +0300 Subject: [PATCH 4/4] Kerberos tests: cached service login case, JVM state cleanup, e2e fixes Module test: - secondRequestUsesCachedServiceLogin: two requests on one module instance; the second must succeed without a new service login, which TestModule counts through createUpdateKrb5ConfigFile(). Without the count a lost cached subject would pass: the module logs in again when the cached subject is null. - The untrusted-realm assertion points at #1174. - KerberosTestRealm.stop() restores the JAAS configuration installed before start(), logs the client out and reloads the JDK's krb5.conf (sun.security.krb5.Config.refresh()) after restoring the properties. e2e: - The browser's Negotiate helper server listens on the address the OpenAM host name resolves to; when that is not an address of this machine the case is skipped with the address in the message. - XUI login form selectors, submitXuiLogin() and idFromSession() move to e2e/common/openam-commons.mjs, used by the Kerberos and XUI specs. - The MIT KDC entrypoint quotes the password inside the kadmin.local query and checks that both principals exist: kadmin.local -q exits with 0 when the query fails. - krb5.conf.template carries the CDDL header. build.yml: krb5-user is installed in its own step after the Playwright install, without a second apt-get update; the cleanup step also removes openam-kdc. --- .github/workflows/build.yml | 19 ++++++--- e2e/common/openam-commons.mjs | 35 ++++++++++++++++ e2e/kerberos/kerberos.spec.mjs | 42 +++++++------------ e2e/kerberos/krb5.conf.template | 16 +++++++ e2e/kerberos/providers/mit/entrypoint.sh | 10 ++++- e2e/xui/xui-httponly.spec.mjs | 36 +++------------- .../windowsdesktopsso/KerberosTestRealm.java | 30 +++++++++---- .../WindowsDesktopSSOKerberosTest.java | 27 ++++++++++-- 8 files changed, 138 insertions(+), 77 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index d1a788bcae..861d942a99 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -302,16 +302,23 @@ jobs: with: sparse-checkout: e2e - - name: UI Smoke Tests (Playwright) - HttpOnly disabled - env: - EXPECT_COOKIE_HTTPONLY: "false" + - name: Install Playwright run: | cd e2e npm init -y npm install @playwright/test npx playwright install chromium --with-deps - # MIT Kerberos client for the Kerberos spec (kinit, klist); libgssapi_krb5 is already on the runner - sudo apt-get update && sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends krb5-user gettext-base + + # MIT Kerberos client for the Kerberos spec (kinit, klist); libgssapi_krb5 is already on the runner. + # No apt-get update: playwright install --with-deps has just run it. + - name: Install MIT Kerberos client + run: sudo DEBIAN_FRONTEND=noninteractive apt-get install -y --no-install-recommends krb5-user gettext-base + + - name: UI Smoke Tests (Playwright) - HttpOnly disabled + env: + EXPECT_COOKIE_HTTPONLY: "false" + run: | + cd e2e npx playwright test --reporter=list - name: Enable HttpOnly session cookie on OpenAM IDP and restart @@ -351,6 +358,8 @@ jobs: shell: bash run: | docker stop openam-idp opendj-idp opendj-sp openam-sp + # The Kerberos spec removes its KDC in afterAll; this covers a run that did not get there. + docker rm -f openam-kdc >/dev/null 2>&1 || true docker network rm test-openam - name: Docker multi-server test diff --git a/e2e/common/openam-commons.mjs b/e2e/common/openam-commons.mjs index 6f84272277..48c2c79333 100644 --- a/e2e/common/openam-commons.mjs +++ b/e2e/common/openam-commons.mjs @@ -14,6 +14,8 @@ * Copyright 2026 3A Systems, LLC. */ +import { expect } from "@playwright/test"; + export const OPENAM_BASE = process.env.OPENAM_BASE_URL ?? "http://openam.example.org:8080/openam"; export const ADMIN_USER = process.env.OPENAM_ADMIN_USER ?? "amadmin"; export const ADMIN_PASS = process.env.OPENAM_ADMIN_PASS ?? "ampassword"; @@ -37,4 +39,37 @@ export async function getAuthToken(request, username, password) { }); const json = await resp.json(); return json.tokenId; +} + +// XUI / LESS-based OpenAM login form selectors +export const XUI_LOGIN = { + usernameInput: "#idToken1", + passwordInput: "#idToken2", + // The submit button id varies between XUI builds (loginButton / loginButton_0 / none), + // so match by submit type as the working SAML spec does. + loginButton: "#loginButton, input[type=\"submit\"], button[type=\"submit\"]", +}; + +/** Fill in and submit the XUI login form once the page shows it. */ +export async function submitXuiLogin(page, username, password) { + await expect(page.locator(XUI_LOGIN.usernameInput)).toBeVisible({ timeout: 30_000 }); + await page.fill(XUI_LOGIN.usernameInput, username); + await page.fill(XUI_LOGIN.passwordInput, password); + await page.locator(XUI_LOGIN.loginButton).first().click(); +} + +/** + * Resolve the id of the active session from the (auto-sent) session cookie, or from the given token. + * Returns null when there is no valid session. + */ +export async function idFromSession(request, tokenId) { + const headers = { "Accept-API-Version": "protocol=1.0,resource=2.0" }; + if (tokenId) { + headers.iPlanetDirectoryPro = tokenId; + } + const resp = await request.post(`${OPENAM_BASE}/json/users?_action=idFromSession`, { headers }); + if (!resp.ok()) { + return null; + } + return (await resp.json()).id; } \ No newline at end of file diff --git a/e2e/kerberos/kerberos.spec.mjs b/e2e/kerberos/kerberos.spec.mjs index 351eeb9088..c612f01808 100644 --- a/e2e/kerberos/kerberos.spec.mjs +++ b/e2e/kerberos/kerberos.spec.mjs @@ -35,7 +35,7 @@ import { readFileSync } from "fs"; import { createServer } from "http"; import { resolve } from "path"; import { fileURLToPath } from "url"; -import { OPENAM_BASE, USERNAME, PASSWORD } from "../common/openam-commons.mjs"; +import { OPENAM_BASE, USERNAME, PASSWORD, submitXuiLogin, idFromSession } from "../common/openam-commons.mjs"; const __dirname = fileURLToPath(new URL(".", import.meta.url)); @@ -66,12 +66,6 @@ const REST_NEGOTIATE_BUG = "/json/authenticate rejects a Negotiate header with 4 // reaches this message. const HTTP_CALLBACK_REJECTED = "HttpCallback Authorization Header must be specified in the initial request"; -const SEL = { - usernameInput: "#idToken1", - passwordInput: "#idToken2", - loginButton: "#loginButton, input[type=\"submit\"], button[type=\"submit\"]", -}; - // The browser may answer Negotiate challenges from the OpenAM host only. test.use({ launchOptions: { @@ -105,7 +99,7 @@ function authenticateUrl(indexValue, indexType = "module") { /** * Let the browser answer a Negotiate challenge from a local server on the OpenAM host name and * return the Authorization header it sent. The port is not part of the SPN, so the token is for the - * OpenAM service principal. + * OpenAM service principal. Skips the test when the OpenAM host name is not an address of this machine. */ async function negotiateHeaderFromBrowser(page) { let authorization; @@ -118,7 +112,14 @@ async function negotiateHeaderFromBrowser(page) { res.end(); } }); - await new Promise((listening) => server.listen(0, "127.0.0.1", listening)); + // On the address the OpenAM host name resolves to, which is where the browser connects. + try { + await new Promise((listening, failed) => server.once("error", failed).listen(0, OPENAM_HOST, listening)); + } catch (err) { + test.skip(err.code === "EADDRNOTAVAIL", `${OPENAM_HOST} resolves to ${err.address}, not to this machine: ` + + "the browser cannot reach a local server on that name"); + throw err; + } try { await page.goto(`http://${OPENAM_HOST}:${server.address().port}/`); } finally { @@ -128,19 +129,6 @@ async function negotiateHeaderFromBrowser(page) { return authorization; } -/** Resolve the username of the active session from the session cookie or the given token. */ -async function idFromSession(request, tokenId) { - const headers = { "Accept-API-Version": "protocol=1.0,resource=2.0" }; - if (tokenId) { - headers.iPlanetDirectoryPro = tokenId; - } - const resp = await request.post(`${OPENAM_BASE}/json/users?_action=idFromSession`, { headers }); - if (!resp.ok()) { - return null; - } - return String((await resp.json()).id).toLowerCase(); -} - test.beforeAll(() => { // docker build, KDC start, several ssoadm runs and kinit do not fit the default 180 s. test.setTimeout(600_000); @@ -203,7 +191,7 @@ test.describe("OpenAM WindowsDesktopSSO - Kerberos SSO", () => { expect(finish.status(), await finish.text()).toBe(200); // tokenId is absent in HttpOnly mode; the session cookie set by the response is used then. const { tokenId } = await finish.json(); - expect(await idFromSession(page.request, tokenId)).toBe(krb.user.toLowerCase()); + expect(String(await idFromSession(page.request, tokenId)).toLowerCase()).toBe(krb.user.toLowerCase()); }); test("REST: curl --negotiate is rejected with 400 until #1170 is fixed", async () => { @@ -259,11 +247,9 @@ test.describe("OpenAM WindowsDesktopSSO - Kerberos SSO", () => { try { const page = await browser.newPage(); await page.goto(`${OPENAM_BASE}/XUI/#login/&service=${encodeURIComponent(krb.chain)}`); - await expect(page.locator(SEL.usernameInput)).toBeVisible({ timeout: 30_000 }); - await page.fill(SEL.usernameInput, USERNAME); - await page.fill(SEL.passwordInput, PASSWORD); - await page.locator(SEL.loginButton).first().click(); - await expect.poll(() => idFromSession(page.request), { timeout: 30_000 }).toBe(USERNAME.toLowerCase()); + await submitXuiLogin(page, USERNAME, PASSWORD); + await expect.poll(async () => String(await idFromSession(page.request)).toLowerCase(), { timeout: 30_000 }) + .toBe(USERNAME.toLowerCase()); } finally { await browser.close(); } diff --git a/e2e/kerberos/krb5.conf.template b/e2e/kerberos/krb5.conf.template index c887ae4e63..cc483129f9 100644 --- a/e2e/kerberos/krb5.conf.template +++ b/e2e/kerberos/krb5.conf.template @@ -1,3 +1,19 @@ +# The contents of this file are subject to the terms of the Common Development and +# Distribution License (the License). You may not use this file except in compliance with the +# License. +# +# You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the +# specific language governing permission and limitations under the License. +# +# When distributing Covered Software, include this CDDL Header Notice in each file and include +# the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL +# Header, with the fields enclosed by brackets [] replaced by your own identifying +# information: "Portions copyright [year] [name of copyright owner]". +# +# Copyright 2026 3A Systems, LLC. + +# Client krb5.conf for the browser and curl. bootstrap.sh renders it into .work/krb5.conf with envsubst. + [libdefaults] default_realm = ${KRB_REALM} dns_lookup_kdc = false diff --git a/e2e/kerberos/providers/mit/entrypoint.sh b/e2e/kerberos/providers/mit/entrypoint.sh index 65e5aa755a..1b940e68b6 100644 --- a/e2e/kerberos/providers/mit/entrypoint.sh +++ b/e2e/kerberos/providers/mit/entrypoint.sh @@ -50,8 +50,16 @@ EOF : > /etc/krb5kdc/kadm5.acl kdb5_util create -s -r "$KRB_REALM" -P "$KRB_KDC_ADMIN_PASSWORD" -kadmin.local -r "$KRB_REALM" -q "addprinc -pw $KRB_PASSWORD $KRB_USER@$KRB_REALM" +# kadmin.local splits the query into words itself: quote the password, doubling any '"' in it. +kadmin.local -r "$KRB_REALM" -q "addprinc -pw \"${KRB_PASSWORD//\"/\"\"}\" $KRB_USER@$KRB_REALM" kadmin.local -r "$KRB_REALM" -q "addprinc -randkey $KRB_SPN@$KRB_REALM" +# kadmin.local exits with 0 even when the query fails. +for principal in "$KRB_USER@$KRB_REALM" "$KRB_SPN@$KRB_REALM"; do + if ! kadmin.local -r "$KRB_REALM" -q "getprinc $principal" 2>/dev/null | grep -Fx "Principal: $principal" >/dev/null; then + echo "Principal $principal was not created" >&2 + exit 1 + fi +done # Export under a temporary name so that a reader never sees a partly written keytab. kadmin.local -r "$KRB_REALM" -q "ktadd -k /keytabs/http.keytab.tmp $KRB_SPN@$KRB_REALM" mv /keytabs/http.keytab.tmp /keytabs/http.keytab diff --git a/e2e/xui/xui-httponly.spec.mjs b/e2e/xui/xui-httponly.spec.mjs index b1b7598118..5230753432 100644 --- a/e2e/xui/xui-httponly.spec.mjs +++ b/e2e/xui/xui-httponly.spec.mjs @@ -73,16 +73,9 @@ */ import { test, expect } from "@playwright/test"; -import { OPENAM_BASE, USERNAME, PASSWORD, ADMIN_USER, ADMIN_PASS } from "../common/openam-commons.mjs"; - -// XUI / LESS-based OpenAM login form selectors -const SEL = { - usernameInput: "#idToken1", - passwordInput: "#idToken2", - // The submit button id varies between XUI builds (loginButton / loginButton_0 / none), - // so match by submit type as the working SAML spec does. - loginButton: "#loginButton, input[type=\"submit\"], button[type=\"submit\"]", -}; +import { + OPENAM_BASE, USERNAME, PASSWORD, ADMIN_USER, ADMIN_PASS, XUI_LOGIN, submitXuiLogin, idFromSession, +} from "../common/openam-commons.mjs"; // Optional hard expectation for the CI matrix ("true" | "false" | undefined) const EXPECT_HTTPONLY = process.env.EXPECT_COOKIE_HTTPONLY; @@ -98,24 +91,10 @@ async function getServerInfo(request) { /** Log in through the XUI login form and wait until the user leaves the #login route. */ async function loginViaXui(page, user, pass) { await page.goto(`${OPENAM_BASE}/XUI/#login/`); - await expect(page.locator(SEL.usernameInput)).toBeVisible({ timeout: 20_000 }); - await page.fill(SEL.usernameInput, user); - await page.fill(SEL.passwordInput, pass); - await page.locator(SEL.loginButton).first().click(); + await submitXuiLogin(page, user, pass); await page.waitForURL((url) => !url.hash.startsWith("#login"), { timeout: 30_000 }); } -/** Resolve the username of the active session from the (auto-sent) session cookie. */ -async function idFromSession(request) { - const resp = await request.post(`${OPENAM_BASE}/json/users?_action=idFromSession`, { - headers: { "Accept-API-Version": "protocol=1.0,resource=2.0" }, - }); - if (!resp.ok()) { - return null; - } - return (await resp.json()).id; -} - test.describe("OpenAM XUI - HttpOnly session cookie", () => { test("XUI login/session/logout work and cookie flag matches server mode", async ({ page, context }) => { // ── 1. Discover the mode the server is actually running in ────────────── @@ -131,10 +110,7 @@ test.describe("OpenAM XUI - HttpOnly session cookie", () => { // ── 2. Log in through the XUI login form ──────────────────────────────── await page.goto(`${OPENAM_BASE}/XUI/#login/`); - await expect(page.locator(SEL.usernameInput)).toBeVisible({ timeout: 20_000 }); - await page.fill(SEL.usernameInput, USERNAME); - await page.fill(SEL.passwordInput, PASSWORD); - await page.locator(SEL.loginButton).first().click(); + await submitXuiLogin(page, USERNAME, PASSWORD); // ── 3. XUI must consider the user logged in (leaves the #login route) ──── await page.waitForURL((url) => !url.hash.startsWith("#login"), { timeout: 30_000 }); @@ -198,7 +174,7 @@ test.describe("OpenAM XUI - HttpOnly session cookie", () => { // ── 3. The user must still be logged in (not bounced back to #login) ───── await page.waitForLoadState("networkidle"); expect(page.url(), "reload must not redirect to the login page").not.toContain("#login"); - await expect(page.locator(SEL.usernameInput), "login form must not be shown after reload") + await expect(page.locator(XUI_LOGIN.usernameInput), "login form must not be shown after reload") .toHaveCount(0); // ── 4. The session is still resolvable after the reload ───────────────── diff --git a/openam-authentication/openam-auth-windowsdesktopsso/src/test/java/com/sun/identity/authentication/modules/windowsdesktopsso/KerberosTestRealm.java b/openam-authentication/openam-auth-windowsdesktopsso/src/test/java/com/sun/identity/authentication/modules/windowsdesktopsso/KerberosTestRealm.java index ee4612c257..511cfd45fe 100644 --- a/openam-authentication/openam-auth-windowsdesktopsso/src/test/java/com/sun/identity/authentication/modules/windowsdesktopsso/KerberosTestRealm.java +++ b/openam-authentication/openam-auth-windowsdesktopsso/src/test/java/com/sun/identity/authentication/modules/windowsdesktopsso/KerberosTestRealm.java @@ -11,7 +11,7 @@ * Header, with the fields enclosed by brackets [] replaced by your own identifying * information: "Portions copyright [year] [name of copyright owner]". * - * Copyright 2026 3A Systems LLC. + * Copyright 2026 3A Systems, LLC. */ package com.sun.identity.authentication.modules.windowsdesktopsso; @@ -22,6 +22,7 @@ import org.ietf.jgss.GSSManager; import org.ietf.jgss.GSSName; import org.ietf.jgss.Oid; +import sun.security.krb5.Config; import javax.security.auth.Subject; import javax.security.auth.login.AppConfigurationEntry; @@ -40,8 +41,9 @@ * Not part of the PowerMock test class: PowerMock routes that class's calls to JDK classes through reflection, * which fails for JDK packages that the surefire argLine does not open, such as java.nio.file. *

- * Changes JVM-wide state while running: {@code java.security.krb5.conf} points at the KDC's krb5.conf, and the - * global JAAS configuration has the client entry. {@link #stop()} restores both. + * Changes JVM-wide state while running: {@code java.security.krb5.conf} points at the KDC's krb5.conf, the JDK caches + * that krb5.conf, and the global JAAS configuration has the client entry. {@link #stop()} restores the properties and + * the JAAS configuration and reloads the JDK's krb5.conf. */ final class KerberosTestRealm { @@ -52,8 +54,10 @@ final class KerberosTestRealm { "java.security.krb5.kdc", "java.security.auth.login.config"}; private final Map savedProperties = new HashMap<>(); + private Configuration savedConfiguration; private File workDir; private SimpleKdcServer kdc; + private LoginContext clientLogin; private Subject client; /** @@ -68,6 +72,8 @@ void start(String realm, String user, String... services) throws Exception { for (String name : SYSTEM_PROPERTIES) { savedProperties.put(name, System.getProperty(name)); } + // Creates the default configuration if none is installed yet, so the one restored by stop() is that default. + savedConfiguration = Configuration.getConfiguration(); workDir = Files.createTempDirectory("kerberos-test-realm").toFile(); kdc = new SimpleKdcServer(); kdc.setWorkDir(workDir); @@ -84,9 +90,9 @@ void start(String realm, String user, String... services) throws Exception { // The default JAAS configuration cannot be created once java.security.auth.login.config names a missing // file, as the "/dev/null" set by the module does on Windows. A configuration set here is used instead. Configuration.setConfiguration(new ClientConfiguration(user, exportKeytab(user))); - LoginContext login = new LoginContext(CLIENT_LOGIN); - login.login(); - client = login.getSubject(); + clientLogin = new LoginContext(CLIENT_LOGIN); + clientLogin.login(); + client = clientLogin.getSubject(); } /** @@ -124,9 +130,10 @@ byte[] initialToken(String servicePrincipal, String mech) throws Exception { } /** - * Stops the KDC and restores the JAAS configuration and the system properties. + * Stops the KDC, logs the user out, restores the JAAS configuration and the system properties and reloads the + * JDK's krb5.conf. * - * @throws Exception if the KDC does not stop. + * @throws Exception if the KDC does not stop, the user cannot be logged out or krb5.conf cannot be reloaded. */ void stop() throws Exception { try { @@ -134,7 +141,7 @@ void stop() throws Exception { kdc.stop(); } } finally { - Configuration.setConfiguration(null); + Configuration.setConfiguration(savedConfiguration); for (Map.Entry property : savedProperties.entrySet()) { if (property.getValue() == null) { System.clearProperty(property.getKey()); @@ -143,6 +150,11 @@ void stop() throws Exception { } } FileUtils.deleteQuietly(workDir); + // Drops Kerby's realm and KDC port, which later Kerberos code in this JVM would otherwise still use. + Config.refresh(); + if (clientLogin != null) { + clientLogin.logout(); + } } } diff --git a/openam-authentication/openam-auth-windowsdesktopsso/src/test/java/com/sun/identity/authentication/modules/windowsdesktopsso/WindowsDesktopSSOKerberosTest.java b/openam-authentication/openam-auth-windowsdesktopsso/src/test/java/com/sun/identity/authentication/modules/windowsdesktopsso/WindowsDesktopSSOKerberosTest.java index f8e2118d8e..bed0ef0946 100644 --- a/openam-authentication/openam-auth-windowsdesktopsso/src/test/java/com/sun/identity/authentication/modules/windowsdesktopsso/WindowsDesktopSSOKerberosTest.java +++ b/openam-authentication/openam-auth-windowsdesktopsso/src/test/java/com/sun/identity/authentication/modules/windowsdesktopsso/WindowsDesktopSSOKerberosTest.java @@ -11,7 +11,7 @@ * Header, with the fields enclosed by brackets [] replaced by your own identifying * information: "Portions copyright [year] [name of copyright owner]". * - * Copyright 2026 3A Systems LLC. + * Copyright 2026 3A Systems, LLC. */ package com.sun.identity.authentication.modules.windowsdesktopsso; @@ -115,6 +115,19 @@ public void kerberosTokenLogsUserIn() throws Exception { assertEquals(module.getPrincipal().getName(), USER); } + @Test + public void secondRequestUsesCachedServiceLogin() throws Exception { + TestModule first = newModule("cached", negotiate(SERVICE_PRINCIPAL, SPNEGO_MECH), options()); + assertEquals(first.process(null, ISAuthConstants.LOGIN_START), ISAuthConstants.LOGIN_SUCCEED); + assertEquals(first.serviceLogins, 1); + + // As every request after the first one to a module instance: the service subject comes from the cache. + TestModule second = newModule("cached", negotiate(SERVICE_PRINCIPAL, SPNEGO_MECH), options()); + assertEquals(second.process(null, ISAuthConstants.LOGIN_START), ISAuthConstants.LOGIN_SUCCEED); + assertEquals(second.serviceLogins, 0, "the second request should not log the service in again"); + assertEquals(second.getPrincipal().getName(), USER); + } + @Test public void returnRealmKeepsRealmInUserName() throws Exception { Map options = options(); @@ -141,9 +154,12 @@ public void tokenFromUntrustedRealmDoesNotLogUserIn() throws Exception { options.put(ATTR_PREFIX + "kerberos-realms-trusted", Collections.singleton("OTHER.COM")); TestModule module = newModule("untrustedRealm", negotiate(SERVICE_PRINCIPAL, SPNEGO_MECH), options); - // The "untrustedToken" AuthLoginException is thrown inside Subject.doAs, and process() rethrows only a - // wrapped GSSException, so the module ignores the token instead of failing. - assertEquals(module.process(null, ISAuthConstants.LOGIN_START), ISAuthConstants.LOGIN_IGNORE); + // #1174: the "untrustedToken" AuthLoginException is thrown inside Subject.doAs, and process() rethrows only a + // wrapped GSSException, so the module ignores the token instead of failing. Once #1174 is fixed this turns + // red: expect an AuthLoginException with the "untrustedToken" error code instead. + assertEquals(module.process(null, ISAuthConstants.LOGIN_START), ISAuthConstants.LOGIN_IGNORE, + "the module ignores an untrusted token instead of failing: " + + "https://github.com/OpenIdentityPlatform/OpenAM/issues/1174"); assertNull(module.getPrincipal()); } @@ -202,6 +218,7 @@ private String negotiate(String servicePrincipal, String mech) throws Exception private static final class TestModule extends WindowsDesktopSSO { private final HttpServletRequest request; private int moduleAuthLevel; + private int serviceLogins; TestModule(HttpServletRequest request) { this.request = request; @@ -223,9 +240,11 @@ public boolean setAuthLevel(int authLevel) { return true; } + /** Called once per service login of the module in krb5.conf mode, the default. */ @Override protected void createUpdateKrb5ConfigFile() { // Keep Kerby's krb5.conf: the module would rewrite "kdc = localhost:" as "kdc=localhost", port 88. + serviceLogins++; } } }