Repository navigation
WindowsDesktopSSO (Kerberos/SPNEGO) tests without Windows: Playwright e2e and Kerby module test - #1172
Conversation
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 (OpenIdentityPlatform#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/<name>.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.
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.
…s Negotiate challenge The curl and XUI cases assert the 400 and the HttpCallback message from OpenIdentityPlatform#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 OpenIdentityPlatform#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.
vharseko
left a comment
There was a problem hiding this comment.
Thanks for this: running the module against a real KDC, with no Windows involved, closes a long-standing gap. A few changes are needed before merge; details are inline.
Two problems sit outside the diff, so I filed them separately:
- #1173: after the service credentials expire, a successful retry returns
LOGIN_IGNOREunless message-level debug logging is on. None of the new tests reaches this path. - #1174: the
untrustedToken/context/notfoundbehaviour described in your "Note for reviewers". Please reference it from the assertion (see the inline comment), the way the e2e spec references #1170.
|
|
||
| // 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); |
There was a problem hiding this comment.
| * 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<String, Object> options) { |
There was a problem hiding this comment.
A fresh instance name per test means every test runs only the first-login path of getConfigParams(). The production steady state is never exercised: a second process() on the same instance, which hits the configTable cache and restores serviceSubject from it.
Could you add a case that logs in twice on one instance name? A regression on the cache-hit path (for example serviceSubject not restored, so Subject.doAs runs without the acceptor keys) would currently pass all seven tests.
There was a problem hiding this comment.
Added secondRequestUsesCachedServiceLogin. It sends two requests to one instance name and expects both to return LOGIN_SUCCEED. TestModule counts service logins through createUpdateKrb5ConfigFile(), which the module calls once per service login in krb5.conf mode, and the test asserts that the second request made none.
Your example is caught only because of that counter. With serviceSubject = (Subject) configMap.get(...) deleted, getConfigParams() reaches the serviceSubject == null check and returns false. The module then logs the service in again, and the second login still succeeds. Measured locally:
- assignment deleted: only the new test fails, on the counter (
expected [0] but found [1]); - assignment and null check both deleted (a cache hit with no subject): only the new test fails, with
Failed to authentication.; - the seven existing tests pass under both mutants.
| kdc.stop(); | ||
| } | ||
| } finally { | ||
| Configuration.setConfiguration(null); |
There was a problem hiding this comment.
The class Javadoc says stop() restores the JAAS configuration, but setConfiguration(null) drops whatever Configuration was installed before start() instead of restoring it. Two more pieces of JVM-wide state stay behind:
- the client
LoginContextfromstart()is never logged out; - the JDK keeps its cached krb5 config (Kerby's realm and port) after the file is deleted, until something refreshes it.
A later test class in the same surefire JVM that uses JGSS or Krb5LoginModule without refreshKrb5Config would then talk to a dead KDC, and fail only in that test order. Please save and restore the previous Configuration, log the client out, and refresh the krb5 config once the properties are restored.
There was a problem hiding this comment.
Done. start() saves Configuration.getConfiguration() and stop() puts it back. If no configuration was installed, that call creates the default one, and the default is what gets restored. stop() also logs the client out. Once the properties are restored, it calls sun.security.krb5.Config.refresh(); the root pom already exports sun.security.krb5 to javac and surefire. The class Javadoc now lists all three.
Nothing in this module trips over it today. The module's tests run in their own surefire JVM, and the only other class there, WindowsDesktopSSOTest, uses neither JGSS nor Krb5LoginModule. The module tests pass with the classes in either order (13/13).
| * 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. |
There was a problem hiding this comment.
3A Systems LLC. lacks the comma used in the other new files of this PR (bootstrap.sh, kerberos.env, the spec, the Dockerfile): 3A Systems, LLC.. The same applies to line 14 of WindowsDesktopSSOKerberosTest.java.
There was a problem hiding this comment.
Fixed in both Java files: 3A Systems, LLC.
| @@ -0,0 +1,15 @@ | |||
| [libdefaults] | |||
There was a problem hiding this comment.
This new file has no CDDL header. krb5.conf supports # comments, so it can carry the same header as the other new files.
There was a problem hiding this comment.
Added the header as # comments. MIT kinit accepts the rendered file.
| res.end(); | ||
| } | ||
| }); | ||
| await new Promise((listening) => server.listen(0, "127.0.0.1", listening)); |
There was a problem hiding this comment.
The helper server listens on 127.0.0.1, but the browser is sent to http://${OPENAM_HOST}:<port>/. That only works while the OpenAM host name resolves to loopback, as it does in CI. With OPENAM_BASE_URL pointing at a host that resolves to a container or LAN address, the browser never reaches the server, authorization stays undefined, and the REST two-step case fails with "the browser should answer the Negotiate challenge", which reads like a Kerberos fault.
Please either listen on all interfaces, or check that OPENAM_HOST resolves to loopback and skip the case with a clear message otherwise.
There was a problem hiding this comment.
The server now listens on OPENAM_HOST itself, so Node binds whatever address the name resolves to:
- if that address belongs to this machine, loopback or LAN, the browser reaches the server;
- if it does not, as with a container or a remote address,
listenfails withEADDRNOTAVAIL, and the case is skipped with a message that names the resolved address.
Listening on all interfaces would not have covered the container case, because a container's address is not one of the host's.
| } | ||
|
|
||
| /** Resolve the username of the active session from the session cookie or the given token. */ | ||
| async function idFromSession(request, tokenId) { |
There was a problem hiding this comment.
idFromSession() and the SEL map (line 69) duplicate the private helpers in e2e/xui/xui-httponly.spec.mjs, and the login form fill is repeated too. The comment there notes that the submit button id varies between XUI builds; with two copies, the next change has to be made in both specs. Please move them to e2e/common/openam-commons.mjs next to getAuthToken and use them from both specs.
There was a problem hiding this comment.
Moved XUI_LOGIN (the selectors), submitXuiLogin(page, user, pass) and idFromSession(request, tokenId?) to e2e/common/openam-commons.mjs, and both specs now use them:
idFromSessionreturns the raw id, as the XUI spec's copy did, and callers lowercase it;- the visibility wait in
submitXuiLoginis 30 s, the longer of the two (the XUI spec used 20 s).
saml-test.spec.mjs keeps its own SEL, since its submit selector differs.
| : > /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" |
There was a problem hiding this comment.
$KRB_PASSWORD goes into the -q query unquoted, and the value is meant to be overridable (kerberos.env, mit.sh). A password with a space or a leading - makes addprinc misparse its arguments. A failed -q query does not reliably give kadmin.local a non-zero exit status, so set -e may not stop here: the keytab is still exported, the KDC reports ready, and the failure only surfaces later as "kinit failed" after the retries in bootstrap.sh.
Please quote the value inside the query and check that the principal exists (getprinc) before exporting the keytab.
There was a problem hiding this comment.
Confirmed on this image (krb5 1.20.1-2+deb12u5):
kadmin.local -qexits with 0 even when the query fails;getprincof a missing principal also exits with 0;- an unquoted password with a space fails
addprincwith a usage message, so no principal is created; - a leading
-on its own was harmless, because-pwtakes the next word.
The password is now quoted inside the query, with any " doubled; that is how kadmin's parser escapes it. Before the keytab is exported, both principals are checked with getprinc, by its output, and the entrypoint exits with 1 if either is missing. Tested with two cases:
- the password
-a "b" c:kinitsucceeds; - a user name that kadmin cannot parse: the container exits with
Principal de mo@EXAMPLE.ORG was not created.
| 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 |
There was a problem hiding this comment.
playwright install --with-deps has just run apt-get update, and this line runs it again in the same step. Every run pays for a second index refresh, and a transient mirror error (Hash Sum mismatch, 404) now fails the whole "HttpOnly disabled" smoke step, losing the SAML, OAuth2 and XUI results over a package only the Kerberos spec needs. Please move the install into its own step, without the extra apt-get update.
There was a problem hiding this comment.
Split into three steps:
Install Playwright;Install MIT Kerberos client, now onlyapt-get install: there is no secondapt-get update, because--with-depshas just refreshed the package lists;- the smoke-test step.
A failure in the install step still stops the job before the tests. I left it that way rather than adding continue-on-error, which would quietly turn the Kerberos spec into a skip.
| 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 |
There was a problem hiding this comment.
Every bootstrap rebuilds the KDC image (Docker Hub resolve plus apt-get in the Dockerfile), with up to three attempts, and Playwright runs beforeAll again after a worker restart, so one failed Kerberos case pays for it twice.
Also, only afterAll removes the KDC: the "Stopping containers and removing the network" step in build.yml does not list openam-kdc. If Playwright is killed before afterAll (step timeout, OOM), docker network rm test-openam fails with "network has active endpoints". Please add openam-kdc to that cleanup (tolerating its absence), and consider skipping the build when the image already exists.
There was a problem hiding this comment.
Added docker rm -f openam-kdc to the cleanup step; it tolerates the container being absent. Strictly speaking, the step cannot hit the case you describe. It has no if: always(), so it runs only after a passing Playwright step, and by then afterAll has removed the KDC. The line covers a future change to either of those.
I'd rather keep the build on every bootstrap:
- In the run at 56d1c9e, the cold build plus KDC start took about 15 s of a roughly 41 s bootstrap; most of the rest is ssoadm.
- A warm rebuild with the layer cache takes about 5 s here, and the second bootstrap happens only after a case has already failed.
- Skipping the build when the image exists would keep a stale image after a local edit of
entrypoint.sh, which this round made, unless the tag tracked the content.
That is more machinery than about 5 s on a failure path is worth.
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 OpenIdentityPlatform#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.
vharseko
left a comment
There was a problem hiding this comment.
Thanks, all ten points from the previous round are addressed in 59ffaa8, and I checked each one against the diff:
- #1174 reference: the untrusted-realm assertion now carries the link and says what it has to expect once the issue is fixed.
- Cache-hit path:
secondRequestUsesCachedServiceLogincovers it. Two module objects with one instance name is how production reaches theconfigTablecache, and counting service logins throughcreateUpdateKrb5ConfigFile()catches the case where the module silently logs the service in again, which a plainLOGIN_SUCCEEDcheck would miss. Thanks for running both mutants. KerberosTestRealm.stop(): it restores the previousConfiguration, logs the client out and reloads the krb5 config.- Headers:
3A Systems, LLC.in both Java files, andkrb5.conf.templatenow has the CDDL header. - Helper server: it listens on
OPENAM_HOST, and the case is skipped with the resolved address when that address is not on this machine. You're right that listening on all interfaces would not have covered the container case. In run 38358428477 the REST two-step case passed and was not skipped. - Shared helpers:
XUI_LOGIN,submitXuiLoginandidFromSessionare inopenam-commons.mjs, and the XUI spec passes on them in both HttpOnly modes. entrypoint.sh: the password is quoted for kadmin's parser, and both principals are checked withgetprincbefore the keytab is exported. It's good to know thatkadmin.local -qexits with 0 even on failure.build.yml: the Kerberos client is installed in its own step, without the secondapt-get update, and the cleanup step removesopenam-kdc. You're right that the cleanup step has noif: always(), so this is a safeguard for later.- KDC image build: I accept your reasoning for keeping it. About 5 s on a failure path doesn't justify tracking image content.
CI is green on every leg. Approving.
Summary
Tests the WindowsDesktopSSO module (Kerberos / SPNEGO, "Windows SSO") with a real KDC and real tickets, without Windows or Active Directory. On the server side the module uses only JDK JGSS (
Krb5LoginModulewith a keytab, thenGSSContext.acceptSecContext), so any Kerberos client and any KDC can drive it.The tests run at two levels.
1. Playwright e2e:
e2e/kerberos,build-dockerjobKDC. An MIT KDC built from
providers/mit/Dockerfile(debian:bookworm-slim+krb5-kdc), attached to thetest-openamnetwork. Its 88/tcp is published on127.0.0.1for the runner.bootstrap.shcan be run more than once. In order, it:HTTP/openam.example.orgkeytab intoopenam-idpand checks that the keytab has AES keys;KerberosChainchain (KerberosSUFFICIENT, thenDataStoreREQUIRED) withssoadm;krb5.confand runskinit.bootstrap.sh logsandbootstrap.sh stopare for diagnostics and cleanup. If the bootstrap fails, the spec printsbootstrap.sh logsbefore the KDC container is removed.Clients. Chromium and curl use the ticket through the system GSSAPI library.
KRB5_CONFIGandKRB5CCNAMEpoint ate2e/kerberos/.work/. The browser may answer a Negotiate challenge only from the OpenAM host (--auth-server-allowlist).authIdwith the browser's Negotiate tokencurl --negotiate/json/authenticateanswers 400 when a Negotiate header comes with the request that starts the login (#1170). The two cases assert that 400 and itsHttpCallbackmessage, so curl without GSS-API, a missing token or a broken XUI fragment turns them red. Once #1170 is fixed they turn red as well; they should then assert the session instead.Other details:
kerberos.env, e.g.KRB_REALM,KRB_SPN_HOST,KRB_USER,KRB_MODULE_NAMEandKRB_CHAIN_NAME.KRB_PROVIDER. A provider is a fileproviders/<name>.shthat defineskdc_start,kdc_wait,kdc_export_keytab,kdc_logsandkdc_stop. A Samba AD DC provider can be added later without changing the shared code./UI/Login.XUIFilterredirects it to the XUI.klistis Heimdal. The REST two-step case is also skipped when the OpenAM host name does not resolve to an address of this machine: the browser gets its token from a local server listening on that name.submitXuiLoginandidFromSessionlive ine2e/common/openam-commons.mjs; the XUI HttpOnly spec uses them too.build.yml. Installskrb5-userandgettext-basein a step of its own, after the Playwright install and before the first Playwright run (HttpOnly disabled), and removesopenam-kdcin the cleanup step. The Kerberos bootstrap takes about 40-55 s.2. Module test against an embedded Apache Kerby KDC
WindowsDesktopSSOKerberosTest.org.apache.kerby:kerb-simplekdc2.1.2, test scope. Its version is thekerby.versionproperty in the root pom.KerberosTestRealmkeeps the KDC, JAAS and JGSS code out of the PowerMock-prepared test class. PowerMock routes that class's JDK calls through reflection, which fails for packages the surefireargLinedoes not open, such asjava.nio.file. Itsstop()restores the JAAS configuration and the krb5 system properties and reloads the JDK's cached krb5.conf.returnRealmkeeps the realm in the user name;auth;token.createUpdateKrb5ConfigFile()is a no-op in the test. The module splits the KDC attribute on:and cannot express Kerby's port, so the test keeps Kerby's ownkrb5.conf.java.security.auth.login.configto/dev/null, a path that does not exist there, and the JDK's default configuration then fails to load.Note for reviewers
The untrusted-realm case pins the current behaviour: the module returns
LOGIN_IGNOREinstead of failing withuntrustedToken. The exception is thrown insideSubject.doAs, andprocess()rethrows only a wrappedGSSException. The user is not logged in. In a chain, however, the module counts as ignored, not as failed. This is tracked in #1174, and the assertion links it: once #1174 is fixed, the case has to expect anAuthLoginExceptionwithuntrustedToken.Testing
build-mavenlegs ran the module's tests 13/13 (8 new ones and the 5 existing ones): ubuntu with JDK 11/17/21/25/27, macOS with JDK 11/27 and Windows with JDK 11/27. Inbuild-docker, all four Kerberos cases passed ("12 passed, 1 skipped", the skipped one being the existing HttpOnly step-up test), and the XUI spec on the shared helpers passed with HttpOnly enabled too ("3 passed").build-mavenlegs ran the module's tests 12/12 (7 new ones and the 5 existingWindowsDesktopSSOTestones): ubuntu with JDK 11/17/21/25/27, macOS with JDK 11/27 and Windows with JDK 11/27.release_version): allbuild-mavenlegs passed. Inbuild-docker, all four Kerberos cases passed, the curl and XUI ones with the 400 from WindowsDesktopSSO: /json/authenticate answers 400 to a Negotiate header, so Kerberos SSO through the XUI fails #1170. The other specs were not affected ("12 passed, 1 skipped"; the skipped one is the existing HttpOnly step-up test).parseTokenreturningnull, the trusted-realm check disabled,returnRealmignored. The cache case fails when the cached service subject is not restored, both with and without the module's fallback to a new service login.KRB_REALM=TEST.ORG.Related: #1170, #1174