Skills
TC Sso Keycloak
Skill
tc-sso-keycloak. Stand up Keycloak as a shared OIDC identity provider and configure Teamcenter to authenticate against it. Covers where the IdP must live and why the two obvious placements are wrong, the issuer-hostname rule that silently breaks token validation while returning 200, the fact that Teamcenter speaks OIDC only through Security Services in federation mode, the THREE separate truststores one login crosses, and the user-id matching constraint that decides whether the job takes a day or a week. Use for any Teamcenter SSO, Keycloak, OIDC or federated-login work, and before onboarding any further application to shared identity.
Provenance is marked throughout. EXERCISED means it came back from a real call on this estate (TC 2606 on vm2606, Keycloak 24 on the Hyper-V host, 2026-08-15). DERIVED means it was read out of shipped documentation and has not been run. Do not promote a DERIVED line without running it.
0. The one-paragraph version
Teamcenter does not speak OIDC. It authenticates through Teamcenter Security Services (TcSS) running in federation mode, where TcSS is the OAuth relying party and Keycloak is the OpenID Provider. So "configure Teamcenter for SSO" is really three jobs: stand up an IdP, install TcSS, and make one hostname and one user-id namespace agree across four processes that each trust certificates differently.
1. Where the IdP goes, and why the two obvious answers are wrong
EXERCISED. Three placements were considered:
- Reuse the Keycloak bundled with another product: NO. The System Modeler for SysML V2
installer ships one. Its
setup.shdrops thekeycloakdatabase on re-run (line 151) andsetup.sh downremoves the container entirely. It also binds nginx to host 80/443, which had already collided with another stack once. Shared identity must not have its lifecycle owned by one product's installer. - Inside the Teamcenter VM: NO. The VM exists to be checkpointed and reverted. An IdP inside it vanishes for every other consumer the moment it rolls back.
- Its own compose stack on the host: YES. Survives VM reverts, reachable from guest, containers and host browser alike, and its entire state is one Postgres volume plus an exported realm JSON.
WARNING. The host placement has a dependency worth stating up front, because it was missed the
first time: the IdP's availability is now coupled to the container runtime, which competes with the
VM for host RAM. On this estate a ballooning VM killed Docker Desktop outright twice, taking
Keycloak down with it. Give the VM a dynamic memory maximum that leaves real headroom (32 GB
max on a 64 GB host was not enough; 24 GB was). Set restart: unless-stopped so the IdP returns by
itself: EXERCISED, it survived a hard reboot and a Docker crash with realm data intact and no
intervention.
Docker's own failure mode after an unclean shutdown, EXERCISED: a stale AF_UNIX socket
(...\Docker\run\*.sock, mode -a---l, a reparse point) blocks startup and cannot be deleted
by Remove-Item, File.Delete, fsutil reparsepoint delete or del, all of which fail with
error 1920. The fix is renaming the parent run directory aside; Docker recreates it.
2. The issuer hostname is the load-bearing decision
Every consumer must reach the IdP under one name, because that string is the iss claim. The
name is constant; the address it resolves to differs per consumer.
| Consumer | Resolves to | How |
|---|---|---|
| Host browser and host apps | 127.0.0.1 |
host hosts file |
| The Teamcenter guest | the host's address on the VM switch | guest hosts file |
| Containers / k8s | host gateway | --add-host or a hostAlias |
☠☠ The "Containers / k8s" row is not a config step, it is a SILENT FAILURE waiting to happen.
EXERCISED 2026-08-19, hit twice in two different pods during the VeriThreader onboarding. On Docker
Desktop, a pod inherits the host's hosts file. If the host maps the issuer name to 127.0.0.1
(the normal host-browser mapping from the row above) and nobody has yet added a hostAliases entry
for the pod, the name still resolves successfully inside the pod - to the pod's own loopback,
not the host. DNS does not fail, nslookup/getent reports success, and a bare "does this name
resolve" check passes. Only the actual connection attempt on that resolved address fails, or worse,
silently returns nothing useful:
- The auth pod:
sso.xcelerator.local->127.0.0.1inside the pod, so the OIDC back-channel call would have connected to the pod itself and failed, not to Keycloak. - VeriThreader's core pod:
auth.threader.com->127.0.0.1brokeJWT_HOST, and the failure mode was not a network error - no delegated-login/SSO option rendered in the UI at all, which reads as "SSO was never configured," not as a DNS problem, and cost real debugging time before the actual cause surfaced.
⇒ Verify the candidate address before editing any manifest, not after: curl --resolve <issuer-name>:<port>:<candidate-ip> https://<issuer-name>:<port>/.well-known/openid-configuration
from a shell with the same resolution behavior as the pod (or from inside the pod itself). This
settles in one call whether the address is right, versus discovering it's wrong through a confusing
downstream symptom. Fixed here with explicit hostAliases: the Docker Desktop host-gateway IP for
names that mean "the Windows host," an in-cluster Service ClusterIP for names that mean "another
pod in this cluster."
CRITICAL: KC_HOSTNAME_STRICT and KC_HOSTNAME_STRICT_BACKCHANNEL must BOTH be true.
EXERCISED trap: with them false, issuer and authorization_endpoint stay fixed while
token_endpoint, userinfo_endpoint and jwks_uri follow whatever host the discovery request
arrived on. A consumer fetching discovery via a different address gets backchannel URLs that
disagree with the issuer, token validation fails, and .well-known/openid-configuration returns
HTTP 200 and looks completely normal the whole time.
The control that catches it: fetch the discovery document from two different request hosts and diff them. They must be byte-identical. A single fetch cannot detect this.
Prefer a name that already resolves over a prettier one that does not. On this estate the short
host name siemensdc resolved from the host while the tidy FQDN siemensdc.xcelerator.local
resolved nowhere. A perfect certificate on an unresolvable name fails in a way that reads like a
TLS fault, and sends you debugging the wrong layer.
A third vantage point confirms the same STRICT behavior, 2026-08-19. VeriThreader's Node pod
reached Keycloak via its hostAliases-mapped IP (a fourth, container-internal address, distinct
from the host and guest paths above) and the discovery document still returned issuer and
token_endpoint both on sso.xcelerator.local rather than following the request's actual address.
Same correct behavior now independently observed from the host, the Teamcenter guest, and a
container - KC_HOSTNAME_STRICT/_BACKCHANNEL hold regardless of which of the table's rows is
doing the asking.
3. THREE truststores, and a green in one says nothing about the others
EXERCISED, and this is the highest-value item in this skill. A single Teamcenter SSO login crosses at least three certificate trust stores. They are independent.
| Store | Who reads it | How to load |
|---|---|---|
| Guest Windows store | browsers on the guest, .NET, PowerShell | Import-Certificate -CertStoreLocation Cert:\LocalMachine\Root |
| Guest JDK truststore | Teamcenter's own back-channel calls | keytool -importcert -cacerts -alias <name> |
| Host Windows store | host browsers, and host Python (ssl loads Windows stores) |
Import-Certificate on the host |
| Node.js bundled CA list | any Node-based consumer's back-channel calls (see section 20) | NODE_EXTRA_CA_CERTS=<path-to-pem> env var |
A JVM does not read the Windows certificate store. Verifying HTTPS from the guest with PowerShell proves nothing about whether Teamcenter will trust the issuer, because the Login Service's token exchange runs on the JVM. This exact false green occurred here.
Verify each store with the client that actually uses it, and use an A/B control:
# JDK: back up cacerts first, then prove the import is the causal difference
java -Djavax.net.ssl.trustStore=<pre-import backup> Probe.java <url> # expect PKIX failure
java Probe.java <url> # expect HTTP 200
A success alone is not evidence: it cannot distinguish "trusted" from "validation disabled". Pair
every pass with a negative control against a differently-signed endpoint, which must fail
CERTIFICATE_VERIFY_FAILED.
Re-run the JDK import after any JDK patch or replacement. A new JDK ships a fresh cacerts and
the alias is gone, which presents as SSO breaking for no visible reason.
Do not conclude a CA is absent from a store without a working check. A faulty store probe here reported the host CA missing and nearly produced a fabricated blocker ("host clients will fail validation") that was simply untrue. The cheap decisive test is an actual request with default validation, plus the negative control above.
4. Teamcenter's side: Security Services in federation mode
DERIVED from "Teamcenter Security" 2606 unless marked otherwise. Sections: Using OpenID Connect authentication (3-83) and Configure your Login Service for federation mode (3-69).
Flow: user hits Teamcenter, is redirected to the TcSS Login Service, which redirects to the OP; the OP returns an authorization code; the Login Service exchanges it back-channel for an ID Token (this is the JVM call from section 3); the user id is read from the token and a session is established.
Register in Keycloak:
| Item | Value |
|---|---|
| Redirect URI | https://<LoginServiceHost>:<Port>/<LoginServiceName>/weblogin/oidc_callback |
| Post-logout redirect URI | the Active Workspace application URI |
Post-logout URIs are registered only for thin clients such as Active Workspace.
Deployment Center, on the TcSS component (tick Use Deployment Center to install TcSS), under
TcSS Login Service Settings: Federation Type = OIDC, Federation URL = the IdP base,
Federation Reply URL = the oidc_callback above, Federation Logout URL = the OP's
end_session_endpoint. Then Save Component Settings, generate deploy scripts, run them.
federation.properties carries tcsso.oidc.client_id, client_secret, auth_endpoint,
token_endpoint, jwks_endpoint, scope, userid_claim, client_auth_method, token_sig_alg.
Passwords must be run through the shipped PasswordEncoder utility.
TcSS is a real install, not a config toggle, and it is absent from a stock Teamcenter. Check
with controls before assuming either way: Test-Path on the expected paths plus a known-good and a
known-bogus path in the same call. Get-ChildItem <missing-path> -ErrorAction SilentlyContinue
returns empty exactly as an empty directory does, so searching for deployed web applications in
a directory that does not exist reads as "nothing is installed" regardless of the truth.
The install guide states Teamcenter must be configured with SSL as well as SSO. Deliver SSL because it is documented, but state honestly whether anyone has actually watched it bind.
5. The user-id constraint decides how big the job is
DERIVED, then EXERCISED. The guide: "Federated user IDs returned by the OP are expected to
match user IDs in Teamcenter." Where they do not match you must deploy the bundled ApacheDS LDAP
to alias them and set GatewayAliasingEnabled=true on the Identity Service. That is a large
detour.
Check this first, before promising a timeline. Make each Keycloak username byte-identical to the Teamcenter user id and the aliasing detour is not needed.
⚠ Narrowed 2026-08-19. This previously read "and none of it is needed", which is wider than what was tested and wider than what is true. What byte-identical ids buy you is that the browser SSO path needs no directory. It does not follow that the deployment needs no LDAP at all: section 17 establishes, from Siemens' own 2606 documentation and from Deployment Center's component definitions, that the Identity Service's LDAP configuration also authenticates server-side credentials for ITK commands and the FTS indexer, which bypass the Login Service and the gateway entirely. An LDAP-free TcSS can be completely correct for browser SSO and still leave that narrow server-side path unauthenticated.
So the honest claim is scoped to the path that was exercised. A reader deciding whether to stand up ApacheDS needs both halves, and this section used to give them only the first. Note also that this estate does run ApacheDS, as a Keycloak User Federation source holding 43 real Teamcenter users, while its aliasing was never needed. Whether that directory is also serving the ITK/FTS path here has not been tested. Read section 17 before concluding either way.
Two traps, both EXERCISED:
- Keycloak lowercases usernames. A Teamcenter user id containing uppercase can never match, and the mismatch surfaces much later as an unexplained failed login rather than an error at provisioning time. Check the whole user set up front and fail loudly on any uppercase id.
- Keycloak rejects usernames shorter than 3 characters
(
{"errorMessage":"error-invalid-length","params":["username",3,255]}). Real Teamcenter estates have two-character ids. Since the id is what must match, the validator moves, not the user:
GET/PUT /admin/realms/<realm>/users/profile
attributes[name=username].validations.length = {"min": 2, "max": 255}
A freshly created realm starts at 3 again, so anything that stands up a realm must set this.
Group membership is NOT required for Teamcenter SSO. TcSS authenticates and hands Teamcenter a user id; authorization stays entirely in Teamcenter's own groups and roles. Mirror groups only for the other consumers of the shared IdP. Do not present group sync as a Teamcenter prerequisite.
6. Extracting the user set from Teamcenter
EXERCISED. Use the OOTB saved query Admin - Group/Role Membership with Group=*,
Role=*. It returns GroupMember objects whose object_name is
<Group>/<Role>/<Display Name> (<user_id>), which yields users, groups, roles and the full
membership triple in one call.
Three failures on the way, each of which reported success:
tc_query_by_type("User")returnsnFound 0even thoughUserhas a populatedobject_stringand instances are readable. It runs theGeneral...saved query, which searches WorkspaceObject-derived types, andUseris a POM object that is not one. This is a distinct variant from the known "type has noobject_name" case, and the usual control (readobject_stringon a known instance) PASSES here. Do not read that zero as absence.executeSavedQueries(plural) does not exist atQuery-2006-03. The working call isexecuteSavedQuerysingular with a requiredlimit. Guessing the plural yields fault 214086 then 214022, neither of which says "wrong operation". Get entry names fromdescribeSavedQueriesrather than guessing the parallel arrays.- Group names contain spaces and dots (
Project Administration,Library Curation Team.dba). Never iterate them withfor g in $(...)in shell: it word-splits one group into two, and an unencoded space breaks the query URL.
7. Writes that return 201 and store the wrong thing
EXERCISED, and the reason provisioning here is Python rather than shell. Creating groups from a
shell loop produced groups named dba\r: Python's stdout emits CRLF on Windows and bash read -r
keeps the carriage return. Every group POST returned HTTP 201. Every subsequent user create then
failed on a group that did not exist.
Two rules follow:
- Verify by reading the realm back and comparing to the source set, never by the status code. The read-back is what exposed this; nothing else would have.
- Precheck names for stray whitespace and control characters and refuse to proceed. A name is the identity here, so a trailing invisible byte is a different object.
8. Verification bar for "SSO works"
In order, because each step can pass while the next is broken:
- Discovery document identical from two or more request hosts (section 2).
- Certificate validated by each client type: guest browser/.NET, guest JVM, host client (section 3), each with a negative control.
- Realm read back and compared to the source user set, membership included.
- A real browser login, ending authenticated in Teamcenter as a real user. Nothing before this proves SSO; everything before it proves only that the parts exist.
9. Blast radius when the Teamcenter URL changes
EXERCISED. Enabling TLS moved Active Workspace from http://<host>:3000 to https://<host>.
The Node gateway binds one port and serves it as HTTP or HTTPS, never both, so the old endpoint
ceases to exist with no grace period.
Everything addressing the old URL breaks at once, and it presents as "Teamcenter is down" rather
than "the URL moved". On this estate that included the tc-mcp profiles (profiles.json), which
serve every automation session on the tier. Sweep for the old URL before cutting over, and
re-verify each consumer by effect afterwards.
10. Onboarding a further application to the shared IdP
One realm, one client per application. For each new consumer: create a confidential client, set its redirect URI and post-logout URI to real resolvable URLs, and hand the application the client id and secret plus the issuer, auth, token and jwks endpoints.
Leave redirectUris EMPTY until the real callback URL exists. An empty list makes the
authorization-code flow fail loudly; a guessed URI redirects somewhere wrong while looking correctly
configured.
EXERCISED: Keycloak does NOT reject plaintext redirect URIs. A throwaway confidential client in
a realm set sslRequired: external accepted http://<host>:<port>/... with HTTP 201, and the
URI read back intact. Registration-time validation only; the runtime leg was not tested. Always set
https explicitly on any non-localhost callback, and do not rely on the platform to enforce it. A
guard that is believed to exist and does not is worse than a known gap, because the next person
skips the explicit setting expecting the platform to catch it.
Watch for two different fields that both look like the application name. On Teamcenter, the
Deployment Center web-app name becomes the URL context path (for example Teamcenter1), while
tcsso.login_service.appid must stay TCSSOLoginService from Security Services 11.3 onward.
Conflating them produces a reply URL that looks right and is not.
A product that ships its own bundled IdP (System Modeler for SysML V2 ships realm sysmlv2, client
sysmlv2-client) is repointed by rewriting its OIDC endpoint, client id and secret settings, and
disabling its own IdP so its installer stops standing up a rival. Check what its installer does on
re-run before relying on that.
When the consuming app lets you pick which claim maps to its internal identity field, map it to
preferred_username, not sub or name. EXERCISED 2026-08-19 onboarding VeriThreader (see
section 20): Keycloak's own sub claim is an opaque internal UUID, unrelated to any downstream
system's user id; name is a display name with no uniqueness or stability guarantee. Only
preferred_username is guaranteed to equal the Teamcenter user id in this realm, because the LDAP
federation provider is deliberately configured with usernameLDAPAttribute=uid for exactly this
reason (section 5). Whichever field the new app treats as its canonical identity key should read
preferred_username, or a second, independent user-matching scheme exists alongside Teamcenter's -
the same class of bug the section 14-18 investigation cost two days chasing. preferred_username
rides in the profile client scope, not bare openid; request openid profile email or the claim
comes back empty.
11. Deployment Center's TcSS panel: the fields and the traps
EXERCISED 2026-08-15, filled directly in the DC web UI (https://<host>:8070/deploymentcenter,
Components tab, TcSS component).
"Use Deployment Center to install TcSS" must be checked. It was found unchecked on a first
deploy attempt. The media scanner still logs the component as available, the component still shows
in Selected Components, and the deploy still reports success - but every sso.tcSSO* property is
written empty, and nothing is actually installed: no listener, no service, no webapp. Check this
checkbox before anything else in the panel.
Federation Type defaults to none, and the Federation URL/Reply/Logout fields do not exist until
you change it to OIDC. They render dynamically once the dropdown changes; do not conclude they
are absent from this DC version without changing the dropdown first.
Three more fields exist below the client ID/secret pair and are easy to miss on scroll:
OIDC Authorization Endpoint, OIDC Authorization token Endpoint, OIDC Authorization jwks Endpoint. Fill these with the realm-scoped absolute URLs (from a live
.well-known/openid-configuration fetch), not the base. Federation URL per the guide's literal
wording is the un-scoped base (https://<host>:<port>, no realm path), and Keycloak only serves
discovery at the realm path, never the base - so if these three are left blank, TcSS has no way to
resolve the realm regardless of what Federation URL says.
The Application Registry table has two rows with different meanings for the same-looking
columns. Row 1 (TCSSOLoginService) is the Login Service itself: its REDIRECTURL is the real
OAuth callback (.../weblogin/oidc_callback), and per the guide its APPLICATIONROOTURL "can
contain any value, or be left empty" (DC ships a placeholder unused_do_not_change, safe to leave).
Row 2 is the actual Teamcenter/AWC application: its APPLICATIONROOTURL and REDIRECTURL are both
"the URL back to the application", i.e. the AWC root, not an OAuth redirect_uri.
LDAP_USERNAME_ATTRIBUTE in this table is not the OIDC claim setting, despite DC's default
value being uid - the same string that looks like it should be the userid claim. It is the LDAP
aliasing attribute name from section 5's Example 1/2, relevant only when using LDAP authentication.
Leave it at the DC default rather than editing it expecting to affect OIDC claim parsing; the actual
claim field is OIDC Authorization User ID inside the Federation section, further down.
The "OIDC Authorization User ID" field defaults to userId. Combined with section 5's finding
that Keycloak does not emit a userId claim by default, this means the DC default is broken out of
the box unless a protocol mapper adds it - which is exactly why section 5 recommends adding mappers
for uid and userId alongside preferred_username, rather than only setting
tcsso.oidc.userid_claim and assuming the DC UI's default matches.
A field can show a red validation marker while holding a perfectly valid value. Every field in the Federation section showed a persistent red bar immediately after being filled with a well-formed URL, indistinguishable at a glance from a genuine validation failure. Do not read the marker alone as evidence of an error; save and watch for the actual error toast (or its absence) instead.
"OIDC Authorization Claim User ID" is a DIFFERENT field from "OIDC Authorization User ID", and
leaving it empty is fatal, not a style choice. EXERCISED: leaving it blank produced a deployed
login.xml with tcsso.oidc.userid_claim empty, and the Login Service threw
SSOConfigurationException: No value found for required String property: tcsso.oidc.userid_claim
on every single request - the visible symptom was the browser landing on
weblogin/null?csrf_form=... with nothing rendering. "OIDC Authorization User ID" (defaulted to
userId) is a different, unrelated field; do not assume filling one covers the other. Set "OIDC
Authorization Claim User ID" to preferred_username (see section 5).
When the login exception log also shows a Vault secret-load ERROR, check whether the auth method
actually uses that secret before chasing it. EXERCISED: startup logged
Failed to load secret for key 'fnd0_tcsso.oidc.signing_jks_file_pwd' from Teamcenter Vault (twice,
for the signing keystore and signing key password) at the same time as the userid_claim failure
above. That looked related and was a dead end: those secrets back private_key_jwt client
authentication, and this deployment used client_secret_basic
(tcsso.oidc.client_auth_method), which never reads them. The service logged
"initialization completed successfully" immediately after those two errors - a strong signal they
were non-fatal for this configuration. The real log to search is
C:\Users\<user>\Siemens\logs\TcSS\server\TcSecurityServices.log (NOT the top-level
security_services\tcss.output, which mangles the Vault warning via a log4j placeholder-count bug
and never shows the actual failing key name).
Verify a login fix through the full chain, not by absence of the error alone. Confirm the exact
previously-broken URL now redirects to a well-formed Keycloak .../protocol/openid-connect/auth
request (correct client_id, redirect_uri, scope), THEN confirm Keycloak itself returns
HTTP 200 for that exact URL rather than an invalid-client or invalid-redirect-uri error. Either
half alone is not proof: a clean redirect that Keycloak then rejects is exactly as broken as no
redirect at all.
12. TcSS config values are scrambled, not plaintext, and the private keystore may live in Vault, not on disk
EXERCISED 2026-08-15. Password-shaped values in login.xml/identity.xml
(identityServicePassword, KeyStorePassword, encryption_jks_file_pwd, etc.) look like plaintext
and are not. com.teamcenter._ss.util._StructUtil (teamcenter_sso_common.jar) is a rotor cipher
with public static scramble(String)/unscramble(String). Never hand-edit one of these fields to
"fix" a mismatch without unscrambling both sides first - a scrambled string that looks different from
another scrambled string may unscramble to the same real password, or vice versa.
Recipe, proven end to end: extract the class from the jar, compile a two-line Java harness against
the TcSS + third-party classpath (security_services/*.jar +
dm/third_party/TcSS/TcSS<release>/*.jar + a matching log4j2 jar set - the dependency set is not
obvious from security_services/ alone), and call the real method directly:
System.out.println(com.teamcenter._ss.util._StructUtil.unscramble(configValue));
This is the same "call the real class, don't guess the mechanism" pattern as section elsewhere in this skill, applied one layer deeper.
identity.xml's tcsso.vaultEnabled=true gates which keystore code path runs at all, and it is a
hard branch, not a fallback. Disassembled PKIUtilImplService.loadPrivateKey()
(teamcenter_ssoservice.jar) via javap: if TcVaultServiceConfig.isVAULT_ENABLED() is true, the
method reads Vault secret identityserviceKS under path Teamcenter.Securityservice (via
VaultKVClient), strips PEM cert armor, Base64-decodes it into a JKS blob, and never touches the
local KeyStorePath file at all - that branch is genuinely dead code when Vault is enabled. Any
exception in the Vault branch (including a wrong password) is swallowed, logged as "Unable to load
private key from KeyStore", and the method returns null - producing a cascading
NullPointerException: privateKey is null with no further detail. A missing local keystore file is
not evidence of the actual bug when tcsso.vaultEnabled=true - check the Vault secret first, the
same way (call VaultKVClient.readSecret(...) directly through a compiled harness), before assuming
a file needs to be created.
13. Which shared-secret fields must match login.xml, and which must NOT be assumed to
EXERCISED 2026-08-15, and this cost most of a session before it was caught. Not every
password-shaped field pair between login.xml and identity.xml is a shared secret. Read each
field's own <description> text before syncing it to "fix" a mismatch - the description states the
requirement, or its absence, explicitly:
| Field | Description says | Consequence |
|---|---|---|
identityServicePassword, propertiesPassword |
"...MUST match the same parameter in the TSS Login Service" | Sync to login.xml's value. Genuinely shared. |
KeyStorePassword, PrivateKeyPassword (identity.xml) |
"the password to the keystore used for storing the keypair used to sign and verify oauth2(oidc) jwt token" - no cross-service claim | Do not sync to login.xml. This is the password the real Vault-stored keystore was encrypted with, independent of login.xml's own (differently-purposed) OIDC-encryption keystore. |
The failure mode when this is gotten wrong: DC generates a real, working value for
KeyStorePassword/PrivateKeyPassword at install time, matching whatever is actually in Vault. A
later "fix" that overwrites it to match login.xml (because it looks like the same class of
shared-secret mismatch as section 4's identityServicePassword) breaks a previously-working
keystore load, and the resulting error is identical to a genuinely missing/wrong keystore -
javap-level or Vault-read-level verification is what actually distinguishes them, not the field
names or a plausible-looking pattern match.
14. Native Teamcenter rejects an SSO ticket differently than TcSS does, and the two failures need different diagnosis
EXERCISED 2026-08-15. Once TcSS itself is fully working (real browser login authenticates
through the Login Service and Identity Service cleanly, token generated and exchanged), the request
still has to be accepted by native Teamcenter's own account/credential validation - a completely
separate C++ code path from anything in login.xml/identity.xml. A failure here looks
user-identical to a TcSS failure (same "either the user ID or the password is invalid" text) but
needs a different log and a different fix:
515143: The login attempt failed... Invalid account values
at .../foundation/pom/sss/sss_users.cxx(272), ITK_init_module_login failed at .../infomanager_itk.cxx(2468)
Find it in the native tcserver syslog
(Siemens\logs\Teamcenter\ServerManagers\...\TcServer\tcserver.exe<hash>.syslog), matched by
correlation ID to the TcSS log entry (same sessionId - awLogin/N - requestId triple appears in
both), not in TcSecurityServices.log - TcSS's own log stops at "token exchange succeeded" and has
no visibility into what native Teamcenter does with the ticket next.
Diagnostic move that isolates the layer fast: log in via the classic non-SSO path (real
credentials, Session:login or a JSON-REST/tc_connect-style login) for the same user. If that
succeeds, the account, its password, and ITK_init_module_login on the classic path are all proven
healthy - the failure is specific to the SSO-ticket code path, not the account. This rules out an
entire category of otherwise-plausible causes (LDAP password issues, account lockout, a genuinely
wrong native password) in one cheap call.
Leads worth checking before assuming a novel cause, all confirmed against this environment rather
than applied blind: the Application Registry Trusted flag (verify its real bytecode meaning in
SSOServiceImpl.generateTokens() before flipping it - it is a call-mode check, trustVal == trusted,
not a blanket "make this app trusted" switch, and flipping it wrong breaks an already-working
handoff); TCCS/Session Agent (a desktop rich-client helper, irrelevant to the browser/AWC flow, easy
to mistake for relevant because of the name); generic Support KB causes for this exact error text
(character encoding, case-sensitive WAR paths, tc_profilevars.bat, LDAP encryption) - several of
these assume the older Tomcat-based TcSS architecture (tcssoid.war) and do not apply at all to the
embedded-Jetty single-executable TcSS shipped with 2606; confirm which architecture is actually
installed (Test-Path for the WAR file, with a known-good and known-bogus control) before applying
any of them.
RESOLVED 2026-08-19, corrected in place - this section previously said the root cause was
unfound. It was not tcsso.samauth.userid_claim, and it was not anything in login.xml/
identity.xml at all. See section 18: the native pool manager process itself was stale, holding
state from before TcSS was ever reinstalled. Every lead in the paragraph above was independently
checked and genuinely ruled out - the root cause was one layer further down than any of them.
15. This work does not survive a VM revert, and neither does a native-TC credential change made mid-investigation
EXERCISED 2026-08-15. Every fix in sections 12-14 lives in login.xml/identity.xml on the
guest - none of it is captured by SsoSetup's blueprint (which only covers the Keycloak side; see
that app's own README for the boundary). A VM revert during SSO work throws away all of it, and if
the revert target predates a native TC operation done mid-investigation (a bulk password reset, an
account edit), that operation is thrown away too - which can silently fix or silently break something
else in the same revert, worth checking for rather than assuming only the intended change was undone.
Practical rule going forward for this project: checkpoint immediately before starting TcSS
config work, and consider a second checkpoint once TcSS is freshly installed and unconfigured but
before any manual login.xml/identity.xml edits begin - reverting to that point after a bad
experiment is far cheaper than reinstalling TcSS from scratch, which a pre-SSL checkpoint requires.
16. /AWSSOLogin is Node.js, not a WAR servlet - trace it in the gateway source, not by disassembly
EXERCISED 2026-08-17. After TcSS itself is fully working (real browser login authenticates and
hands off cleanly) and Active Workspace still rejects the login, the natural assumption is that the
final hop lives in more opaque native/Java code. It does not, on this architecture: /AWSSOLogin is
served by plain Node.js in microservices/gateway/lib/sso.js, part of the same Node gateway that
serves the AWC port. Find the real handler for any AWC-facing SSO URL by grepping the gateway source
tree for the literal path string before assuming it needs bytecode-level investigation:
Get-ChildItem -Path 'C:\apps\PLM\tc_root\microservices\gateway\lib' -Include '*.js' -Recurse |
Select-String -Pattern '/AWSSOLogin' -SimpleMatch -List
sso.js builds a direct HTTP POST to native Teamcenter's own Core-2008-06-Session/loginSSO SOA
operation (${routes.tc.target}/RestServices/Core-2008-06-Session/loginSSO, routes.tc.target is
read from gateway/config.json's routes.tc.target, normally http://<host>:8080/tc). This is the
actual mechanism behind the browser-facing SSO ticket exchange - it POSTs {username, password: <TcSS-issued JWT>, ssoCredentials: <same JWT>, group, role} and interprets the JSON SOA response.
Because it is readable JS, not compiled Java, it is far cheaper to instrument than the native or TcSS
side.
To capture the real request/response crossing this boundary: add temporary logger.error() calls
immediately after the options object is built and immediately after the SOA response is received
(inside processSSOAuthResponse, look for getLoginSSOPostOption and the await post(...) call).
logger.error always writes regardless of configured log level; logger.silly is gated and easy to
miss. Back up sso.js first, restart Teamcenter Process Manager (not WebTier - it owns the
gateway process; see section 9's mechanism for why), reproduce the failure once, read the result from
the gateway's own log under Siemens\logs\TcMSF\gateway*-msf.log (NOT stdout/console - the gateway
runs as a supervised service), then immediately restore the backup and restart Process Manager
again. The captured request will contain a live session token in plaintext - never leave this
logging in place, and never commit a capture containing one.
A JWT captured this way can be decoded without any tool beyond base64: split on ., take the middle
segment, replace -/_ with +//, pad to a multiple of 4 with =, base64-decode. This is often
the fastest way to prove or disprove "is the right identity actually reaching the far side" - decode
the actual token in flight rather than reasoning about what a correctly-configured token should
contain.
17. LDAP is documented as OPTIONAL for OIDC federation when the federated ID matches the TC user ID - a widely-cited requirements line is misleading
EXERCISED 2026-08-17, verified against Siemens' own shipped 2606 documentation with an independent adversarial re-read that could not refute it. The Teamcenter Security guide's "System requirements" topic states unqualified: "Security Services requires an LDAP v3-compliant identity provider." Taken alone this reads as a hard product requirement, and it is easy to conclude a working OIDC deployment needs an LDAP directory behind it (e.g. ApacheDS) purely because a reference/example installation used one.
This is not the governing rule for federated authentication. More specific topics in the same
guide override it: "Using OpenID Connect authentication" states plainly that "Federated user IDs
returned by the OP are expected to match user IDs in Teamcenter. For user IDs that do not match, use
the supplied ApacheDS LDAP to map them to Teamcenter user IDs." LDAP's entire role in OIDC mode is
resolving a mismatch between the federated subject and the Teamcenter user ID - a feature gated by
GatewayAliasingEnabled (default false) - not a precondition for the login itself. When the
federated ID already equals the Teamcenter user ID, no mapping is needed and LDAP plays no role in the
browser SSO path at all.
Confirmed structurally, not just from prose: Deployment Center's own component definition for
fnd0_TcSS_LDAP (dc_contributions\deployablecomponents\fnd0_TcSS_LDAP_DCC.xml) declares it
optional="true" with no dependency edge from the base TcSS component. The installer's own artifact
template (dc_contributions\packages\fnd0_securityservice_artifact.xml) guards the entire
LDAPConfiguration_* block on whether that optional component is connected - an identity.xml with
zero LDAPConfiguration_* params and an empty Contents of Domain Map: startup log line is DC's
documented, correct output for a no-LDAP deployment, not a sign of a broken or incomplete install.
The identity provider is also a documented pluggable SPI
(com.teamcenter.ss.identity.spi.IdentityProvider); LDAPIdentityProvider is only the shipped
default implementation, and its own class carries explicit non-aliased/no-directory code paths
("Aliasing disabled, skipping lookup", "Name for a secure and not aliased authentication").
One real residual LDAP dependency the docs are explicit about, and it is a different failure mode entirely: the Identity Service's LDAP configuration (if present) is also used to authenticate server-side credentials for ITK commands and the FTS indexer, which bypass the Login Service and gateway entirely. An LDAP-free TcSS can be completely correct for browser SSO and still leave that narrow server-side path unauthenticated - a real gap, but not evidence that the browser SSO path itself needs LDAP.
Do not build an LDAP directory on the theory that TcSS structurally requires one - check first whether the federated ID already matches the Teamcenter ID, which is the actual condition the documentation ties the requirement to.
18. The actual root cause of section 14's native rejection: a stale pool manager, not anything in login.xml/identity.xml
EXERCISED 2026-08-19. This is the answer section 14 left open. An LDAP-first isolation plan was
run specifically to settle whether the native 515143 Invalid account values rejection was about
OIDC/Keycloak or about the absence of a real identity provider behind TcSS: ApacheDS stood up,
users provisioned, TcSS reinstalled with Federation Type none (LDAP-only, Keycloak completely out
of the picture). TcSecurityServices.log showed a fully clean LDAP bind, search, and
Authenticated and authorized - and native Teamcenter still rejected the ticket with the identical
error, same file, same line, correlated by session/request ID. That result ruled out the entire
OIDC-vs-LDAP axis in one clean test.
Two further real leads were chased and ruled out with direct evidence, not assumption:
- Classic non-SSO login as the same user succeeded cleanly (real session, real UIDs) - proving
the native account, its password, and
ITK_init_module_loginon the classic path were always healthy, per section 14's own diagnostic move. identityServicePassword/propertiesPasswordunresolved@CHANGE_ME_...@placeholders inidentity.xml- a real, confirmed DC bug (login.xmlhad the real scrambled secret,identity.xmlhad the literal template token; section 13 documents these as a genuine shared secret). Fixed live, TcSS restarted. Made zero difference - byte-identical native rejection afterward. A real bug, correctly fixed, not the cause of this symptom.
The actual cause: the native pool manager service (Teamcenter Server Manager <PoolName> -
TCDB_PoolA on this install, whatever pool is in play elsewhere) had been running continuously
since before the TcSS reinstall even started - untouched by the deploy, by config fixes, by
restarting TcSS itself. Every tcserver.exe worker it spawns is a child of that one long-lived
process. Individual workers do get recycled fairly often, which is exactly why "restart TcSS and
retest" kept looking like a fair, fresh test and kept failing identically - a fresh worker is still
a child of a stale parent, and evidently inherits stale state from it regardless of the worker's own
spawn time (extension registration, license grants, or whatever TcLoginService - the internal,
undocumented service actually handling this handoff, not the public SessionService.loginSSO -
reads once and never refreshes).
The fix, confirmed with hard evidence, not just a retest: Restart-Service on the pool manager
itself. Checked with real process start times before and after
(Get-CimInstance Win32_Process | Select CreationDate, never service State, which says nothing
about staleness). The very next login attempt after the restart produced a log line - SSO token validated / a native login success - that had never appeared once in any prior attempt, using a
freshly-spawned worker under the freshly-restarted pool manager.
Standing rule this establishes for ALL TcSS/SSO work, LDAP or OIDC, on any tier: a TcSS reinstall
or config change is not fully applied until the pool manager service is itself restarted, recycling
every worker it owns. Restarting TcSS alone, or trusting that newly-spawned pool workers picked up a
config change, is not sufficient - the parent process can outlive many worker recycles while still
holding stale state. Verify the config change actually landed in the deployed login.xml/
identity.xml on disk FIRST (a Select-String on the specific param), restart the pool manager
SECOND, and confirm its own process start time changed THIRD - only then is a retest meaningful.
Skipping any of the three steps risks misdiagnosing a stale-pool-manager symptom as "the fix didn't
work" when the fix was never actually exercised.
19. Federation Reply URL does not auto-populate when Federation Type flips to OIDC - DC will happily generate a login flow that sends an empty redirect_uri
EXERCISED 2026-08-19. After sections 11-18's fixes, TcSS successfully redirected the browser to
the real OP authorization endpoint for the first time - genuine progress, TcSS's own config was
otherwise correct - but the OP (Keycloak) rejected the request outright: Invalid parameter: redirect_uri, with redirect_uri= literally empty in the query string TcSS constructed.
The cause was a wrong assumption, not a DC bug this time: Federation Reply URL (in DC's TcSS panel,
directly below Federation URL) was left blank on the theory that DC would auto-derive it from the
Application Registry's oidc_callback row once Federation Type was set to OIDC. It does not.
Confirmed directly against the deployed login.xml: tcsso.federation_reply_url was empty,
matching the empty redirect_uri the OP received byte for byte.
Fix: set Federation Reply URL explicitly to the real callback URL
(https://<tcss-host>:<port>/login/weblogin/oidc_callback), matching exactly what's registered as
the redirect URI on the OP-side client. While in the same panel, also set Federation Logout URL to
the OP's real end_session_endpoint - pull it from the OP's own
.well-known/openid-configuration, don't assume the path; it isn't required for login to succeed
but its absence means TcSS-initiated logout never actually terminates the OP-side session.
Neither of these two fields is marked required in DC's UI, the same shape as section 11's
OIDC Authorization Claim User ID gap - DC's own validation does not reliably flag every field the
runtime actually needs. Do not trust DC's asterisks as a complete list of what's mandatory for a
working OIDC deployment; verify against the actual behavior (a real redirect, a real OP response) or
against the deployed config file directly.
20. Onboarding a Node.js consumer: a fourth truststore, and container-to-host reachability
EXERCISED 2026-08-19, onboarding VeriThreader's authentication-service (Node 20.20.0, running
in a Docker Desktop k8s pod) as a client of this realm.
Node.js is a fourth truststore, distinct from all three in section 3. It reads neither the OS/
Windows certificate store nor a JDK cacerts file - it ships its own bundled CA list and only
extends it via the NODE_EXTRA_CA_CERTS environment variable pointed at a PEM file. A Node-based
OIDC client whose browser-facing login page works perfectly can still fail its own back-channel
discovery/token calls to Keycloak with a TLS trust error, because the front-channel redirect never
touches Node's trust store at all - only the back-channel call does. Exactly the same shape as
section 3's "a green in one store says nothing about the others": mount the realm's CA into the pod
and set NODE_EXTRA_CA_CERTS, then verify the back-channel call specifically, not just the browser
redirect.
CORRECTED 2026-08-19, same day, by the session that raised the point above. VeriThreader's
authentication-service shipped with NODE_TLS_REJECT_UNAUTHORIZED=0 as a vendor default (nobody
here added it) - that disables Node's certificate verification globally for the process, so
NODE_EXTRA_CA_CERTS was moot for this specific pod. Discovery succeeded there with no CA ever
mounted, which proves the CA was never the gate for that container. Check for this env var before
crediting a CA mount with a fix, or the causal story is wrong even though the symptom cleared: a
later reader mounts the CA, sees SSO work, and never learns the pod's TLS verification was off the
whole time. The general rule still holds - a Node consumer that does NOT disable verification needs
NODE_EXTRA_CA_CERTS - just don't assume this one does before checking.
host.docker.internal resolves from inside a Docker Desktop k8s pod to the Windows host, proven
during separate VeriThreader licensing work: localhost:29000 from inside a pod gave
ECONNREFUSED, host.docker.internal:29000 connected. This matters here because this Keycloak
container's issuer hostname is sso.xcelerator.local mapped via the Windows hosts file (section 2) -
a pod has no access to that hosts file, so a hostAliases entry pointing sso.xcelerator.local at
host.docker.internal's resolved address is the way a k8s-hosted consumer reaches the same issuer
name every other consumer uses, rather than inventing a pod-local alternate hostname that would
break the issuer-match rule in section 2. EXERCISED end to end 2026-08-19: VeriThreader's pod
used hostAliases: sso.xcelerator.local -> 192.168.65.254 (Docker Desktop's host-gateway address)
and its discovery fetch returned HTTP 200. Note that IP is Docker Desktop's internal gateway
convention, not a contractual address - stable in practice, but it can move across a Docker Desktop
upgrade. If a working container-side SSO integration breaks with no config change on either side
right after a Docker Desktop update, check whether this address moved before looking anywhere else.
Also found in this onboarding, worth carrying forward: don't assume a shipped app logs its OIDC
client secret responsibly. VeriThreader's auth service defaults to logLevel: silly and writes
its whole provider config block - including client_secret in plaintext - to stdout at startup,
readable by anyone with kubectl logs access to that namespace. Check a new consumer's log output
at whatever level it ships with before treating a client secret as contained to the two systems that
negotiated it.
21. Do not enable PHP display_errors to chase an SSO fault - it can crash the very thing you're debugging
EXERCISED 2026-08-19, debugging VeriThreader's PHP-based core against this realm.
Turning on display_errors to see more detail about a JWT/SSO failure put the core into
CrashLoopBackOff with a completely unrelated-sounding error, Action listener failed to authenticate. Isolated by toggling one variable at a time: display_errors alone crashes the pod,
JWT_PORT alone (a separate variable that was also suspect) is stable. Mechanism: PHP warnings get
written directly into API response bodies when display_errors is on, corrupting JSON that an
internal component then tries to parse - a parse failure surfacing as an authentication failure, in
a component that has nothing to do with authentication.
⇒ Same shape as this skill's other silent/mislabeled-failure sections: the error names the subsystem that happened to be reading the corrupted response, not the subsystem that corrupted it. Before trusting an error message during OIDC debugging of any PHP-based consumer, ask whether a debug flag you just turned on could itself be the cause - and turn it back off as the first troubleshooting step, not the last, if the failure changed shape right after you enabled it.
Also worth carrying forward, EXERCISED the same session: when a consuming app lets you inspect
the actual claim value, use it to confirm the mapping did what you expected, not just that it was
applied. VeriThreader's minted JWT carried "sub":"ed" after the preferred_username mapping from
section 10 - and separately, name resolved to "ed ed" for that account, which would have been a
genuinely bad identity key had the earlier guidance gone the other way. The recommendation in section
10 was load-bearing here, not a stylistic preference.
20. A working native-TC SSO handoff, for a real consumer app - and the trap that looked like a repeat of section 14/18's history
EXERCISED 2026-08-19. System Modeler for SysML V2's TcConnectorExtension
(embeds Active Workspace to link model elements to Teamcenter) was onboarded
to the shared realm per section 10, then tested end to end as a real
federated user (sysml, both a Keycloak user in this realm and a native TC
user, ids matching per section 5). Given sections 14/18's history - a full
night lost to a native-TC rejection that turned out to be a stale pool
manager - the expectation going in was another deep TcSS investigation.
It was not. Real proof the chain worked, gathered before spending any time
on TcSS/native logs: POST .../tc/RestServices/Internal-AWS2-2017-12- DataManagement/getTCSessionAnalyticsInfo - an operation that requires an
already-authenticated native TC session - returned 200, and the browser
console showed genuine Active Workspace startup (Received StartupNotification OK from Active Workspace, Load all services are started). That is real evidence the full chain (Keycloak SSO -> TcSS Login
Service -> Identity Service -> native TC account validation -> authorized
SOA call) worked, not an inference from "the page looks fine."
The actual blocker was a UI trap in the CONSUMER app, not TcSS or
Keycloak. The extension renders its icon on TWO different buttons on a
project page: a toolbar overflow-menu item that fires a background SOA ping
with no session bootstrap (fails loudly, invalid csrf token, easy to
mistake for a real SSO/CSRF bug - it is not, it is just the wrong button) and
the real trigger, a left-edge sidebar button, matching the extension's own
docs ("an additional button... on the left-hand side... under the Browser
view and Library view buttons"). Only the sidebar button starts the real
awLogin/1 SSO flow.
Lesson for this skill generally: before spending an evening on TcSS/native logs per sections 12-19, find an operation that PROVABLY requires a valid native session (not just "the app looks logged in") and check its HTTP status first. A 403/CSRF error from a consumer app's OWN background telemetry call is not evidence the SSO chain is broken - it may just mean that particular call fired before the app's own session-establishment flow ran, which is a consumer-app bug/UI trap, not a TcSS/Keycloak one. Section 14's original diagnostic move (compare against a classic non-SSO login) is still the right escalation if a properly-triggered SSO flow's own SOA calls are the ones failing - don't reach for it on a call that never had a session to begin with.
Not fully verified: the SSO step opens as a browser popup
(#/sso.host.popup in its own URL), and the automated browser tool used for
this test coerced window.open into same-tab navigation rather than a real
popup (confirmed only one tab ever existed). The underlying session/SOA-call
proof above is real and independent of this, but a human clicking through a
genuine popup window was not observed.
22. TcSS can win the boot race against its own Vault dependency - fixed with an SCM service dependency, not a restart
EXERCISED 2026-08-23, corrected same day after a first-pass misdiagnosis - read this whole
section, not just the fix. A live login failure (weblogin/oidc_redirect still worked, but every
attempt then failed generically - TCSSO_SSOSystemException / "General HTTP exception" from
consuming apps) traced to TcSecurityServices.log showing, on every request:
ERROR - OIDCRedirectCommand - No value found for required String property: tcsso.oidc.client_secret
ERROR - OIDCRedirectCommand - Unexpected error: No ApplicationContext found
The obvious read is wrong. tcsso.oidc.client_secret was NOT empty or a placeholder in the
deployed login.xml - it held the correct, live value, byte-verified against
keycloak-sso/.env.clients. This is not section 19's "field left blank" shape.
The real cause was one layer further down, in the Vault connector the Login Service uses to
resolve secrets at startup (tcsso.vaultEnabled=true, same subsystem as section 12). The log's
WARN lines (not the ERROR ones) named it once DEBUG logging was on:
AuthService - VCL::Please enable debug log to get more information:
com.teamcenter.connector.vault.internal.exception.RestException: java.net.ConnectException
RestRequest - VCL::Retrying ...
Every direct test of the same path succeeded, which is what made this confusing: SIEMENSDC
resolved correctly on the guest, Test-NetConnection SIEMENSDC 8200 succeeded, Vault's live TLS
cert chained cleanly to the CA in VAULT_CAPEM (checked by fully parsing the file for ALL
certificates it contains, not trusting a single-cert loader that silently reads only the first
block - rootCACertificate.pem here genuinely does carry both the Intermediate and its root, so
section 12's/16-Aug's "clobbered CA chain" fix does not apply to every Vault-connectivity failure,
only to that specific one). No proxy env vars, no firewall rule blocking java.exe or
TcSecurityServices.exe. Every external probe of the network path was clean while the JVM's own
Vault client kept throwing ConnectException against the identical address.
First-pass read: Restart-Service 'Teamcenter Security Services' fixed it, verified by real
process start time (Get-CimInstance Win32_Process on the service's own ProcessId, not service
State) - the PID and creation timestamp both changed, and zero ConnectException, zero
VCL::Retrying, zero client_secret/ApplicationContext errors appeared afterward. That was read
as section 18's stale-JVM pattern recurring on a different process. It was the right fix and the
wrong causal story.
The real cause, found by a second session reading Windows service start times, not the JVM's
Teamcenter Security Services is StartMode Auto with no RequiredServices - it starts three
seconds after boot. Teamcenter_Vault_Service is Manual, brought up by the startup script
roughly eleven minutes later. TcSS tried to load identity_service_password and its OIDC
secrets from a Vault that was not running yet, failed at TcVaultLsContextListner/OIDCConfiguration
init, and never retried the load again for the life of the process - only the individual REST
calls retried, against a Vault that was never going to answer this session's auth request regardless
of how long it stayed up. The manual restart "fixed" it only because Vault had been running for
ninety minutes by the time the restart happened - any restart performed after Vault came up would
have worked, which is what made it look like the JVM's age was the variable. It was not: the
variable was which of the two services existed first when TcSS's Vault client initialized. This
would have recurred on every boot, not just occasionally - a genuine latent bug, not a one-off
staleness event.
The permanent fix: an explicit SCM service dependency, applied and verified in both directions
sc config "Teamcenter Security Services" depend= Teamcenter_Vault_Service
Verified by reading the SCM graph from both ends, not just the command's exit code:
sc qc "Teamcenter Security Services" -> DEPENDENCIES: Teamcenter_Vault_Service
Get-Service Teamcenter_Vault_Service -> DependentServices: Teamcenter Security Services
Windows now blocks TcSS from starting until Vault reports Running, and starting TcSS (Auto) now
pulls its Manual prerequisite up with it - Vault starts at boot too, as a side effect, not a
separate change.
⚠ Two behaviour changes this introduces, worth knowing before the next SSO investigation:
- A failed Vault start now blocks TcSS from starting at all. Before this fix, TcSS started
successfully and silently failed to authenticate anyone - the service looked healthy while doing
nothing useful. After this fix, a stopped
Teamcenter Security Servicesafter a boot points straight at Vault, not at TcSS itself. Check Vault's own state first on any "TcSS won't start" report from this point forward. - The race window is smaller, not eliminated. The SCM waits for Vault to report service state
Running, which is not the same guarantee as "Vault is actually ready to serve a secret" (the same gap section 15/2'sdoHealthCheckfinding already established for a different consumer). Eleven minutes shrank to a few seconds, not zero. If this exact error signature (client_secret/ApplicationContextfailure right after a boot, clean afterward) reappears, this residual race is the first thing to suspect, before reopening the CA-chain or secret-mismatch theories below.
⇒ The generalizable lesson supersedes section 18's framing for this specific failure: a fix that
works is not proof of the mechanism you attributed it to. The restart-fixed-it observation was real
and reproducible, but the causal story built on top of it (stale JVM state) was inferred, not
measured, and a second read - of Windows service start TIMES rather than the JVM's own log - falsified
it in about the time it took to run Get-CimInstance Win32_Service. Section 18's actual standing
rule (verify a restart by process creation time, not by the error stopping) still holds and is what
caught the real fix's own verification; its CAUSAL explanation (stale in-process state) does not
automatically transfer to every case where a restart happens to be the cure.
How to get the detail that names the real cause: enabling DEBUG on the Vault connector logger
security_services\config\log4j2.xml carries no monitorInterval on its <Configuration>
element, so an edited logger level does NOT take effect live - the service must be restarted to
pick it up, same as any other login.xml/identity.xml change per section 15.
<Logger name="com.teamcenter.connector.vault" level="INFO" additivity="true">
Raise to DEBUG, back up the file first (and back up the current TcSecurityServices.log too -
see the warning below), restart the service, and the very next request produces genuinely
diagnostic detail (VCL::Sending request, VCL::Parse the appRole authentication response,
VCL::VaultServer URL: ...) that plain INFO never surfaces. The WARN-level "please enable
debug log" line already IN the log at INFO is a real, load-bearing hint from the product itself -
worth searching for on any Vault-adjacent TcSS failure before assuming the log has nothing more to
give.
⚠ The real log is not where the install directory search naturally lands. security_services
itself carries no .log/.out/.err anywhere under it - a directory search there for TcSS's own
log genuinely comes back empty, and reads as "logging must be off." It is not off: the actual file
is C:\Users\<user>\Siemens\logs\TcSS\server\TcSecurityServices.log, entirely outside the install
tree, already at INFO for the _ss/ss/federation/vault loggers by default. Report a log
search as scoped to the directory actually searched, not as "no logging exists," per this skill's
own house discipline on stating findings at the scope of what was tested.
⚠⚠ That log directory does not survive every kind of restart. It lives under the Windows user
profile (%USERPROFILE%\Siemens), and at least one TcSS startup/reinstall script path is known to
delete it - a tier restart driven by that script destroys the only evidence of whatever failure
prompted the restart, leaving a clean log with no explanation once it comes back up. A plain
Restart-Service on the Windows service (as used for the fix above) did NOT trigger this - the log
grew rather than reset, confirmed by size and by real content spanning both before and after the
restart in the same file. Back up the log before ANY restart during a live investigation regardless
of which kind is planned, since the distinction is not always obvious from outside the script.
23. RP-initiated logout needs its own fix and its own probe - login working proves nothing about it
EXERCISED 2026-08-23/25. A client that logs users in perfectly can still break logout outright, and the two failures need separate fixes and separate verification - neither implies the other.
The fix: the teamcenter Keycloak client had no post.logout.redirect.uris attribute set at
all. Every consuming app's logout link (AWC at https://siemensdc/, Polarion via TcSS at
https://martini-gaming:4443/) hit Keycloak's .../protocol/openid-connect/logout endpoint and
got rejected, because an unset post.logout.redirect.uris does not fall back to the client's
ordinary redirectUris for the RP-initiated-logout target - it is a genuinely separate field.
Multiple valid post-logout targets on one client are ##-separated (confirmed empirically, not
documented anywhere obvious):
post.logout.redirect.uris = https://siemensdc/*##https://martini-gaming:4443/*
Set via PUT on the client's attributes, verified by re-reading the client afterward, not by the
PUT's own 204.
⚠ The failure text is browser-only - a machine probe cannot match on it. Keycloak's login-time
invalid_redirect_uri failures return recognizable query-string/JSON detail (section 19). Logout
does not: an invalid post_logout_redirect_uri returns a themed HTML 400 page whose first
kilobyte contains no "invalid redirect" string or any other matchable error text at all. Found
when Tier Health's Deployment Health addon built a probe for this exact fix and its first
text-matching implementation let a known-bad control through as a mere warning, because there was
no text to match. The only reliable machine-readable signal is the HTTP status code itself: a
correctly-configured client returns 302 straight to the declared target with redirects
unfollowed; a rejected one returns 400 with no useful body. Verify like this, not by string
matching a response body:
GET {realm}/protocol/openid-connect/logout?post_logout_redirect_uri=<real-target>&client_id=<id>
healthy: 302 -> exactly the declared target
broken: 400, body not diagnostic
⚠⚠ If scripting this probe in PowerShell, Invoke-WebRequest/Invoke-RestMethod THROW on the
healthy 302 once auto-redirect is disabled, in both PowerShell editions - a naive
try/success-path implementation files the GOOD case under transport failure. Use
[System.Net.HttpWebRequest] directly with AllowAutoRedirect = $false and read .StatusCode
from the response (caught inside the WebException on a non-2xx, same as any other
non-auto-following HTTP client) instead of trusting either cmdlet's redirect handling.
Verification bar, matching section 11's login-side discipline: confirm the exact previously-
failing logout URL now returns 302 to the correct target, for EVERY registered post-logout
target separately - a client with two consuming apps can have one fixed and one still broken, and
testing only one proves nothing about the other.
What this check can and cannot see
A logout-endpoint probe of this shape verifies the IdP leg only. It cannot see whether the consuming application actually terminates its own session when the IdP session ends - Polarion here keeps a fully independent local session cookie with no relationship to Keycloak's or TcSS's session state at all (verified: ending the Keycloak session via this exact flow left a real Polarion session still fully authenticated with zero re-prompt). No backchannel-logout hook exists between TcSS and Polarion currently, so there is nothing an automated check could assert on for that half - a check can only report "this is how it's wired," never flip to healthy or broken, which is a permanent-amber shape not worth building until a real hook exists to assert against. This stays a documented architectural fact here, not a checked condition.
⚠⚠ CONFIRMED 2026-08-25 for a SECOND, independent app: Active Workspace itself has the same gap. EXERCISED by a separate session driving a real browser end to end, not derived: signed into AWC as a real user, completed IdP-initiated logout at Keycloak's own confirmation page ("You are signed out. Your session is closed on this browser." - proof the IdP leg genuinely closed, not a missed click), then navigated straight back to AWC and landed fully signed in, no redirect to the IdP, avatar and Favorites/Recent/Tasks all rendering normally. The control that makes this mean something: signing out through AWC's own user-menu ("Sign Out") DOES terminate the session correctly in the same suite - only the IdP-initiated path is broken, not logout generally.
⇒ This is not a Polarion peculiarity - it is this deployment's teamcenter Keycloak client
having no backchannel.logout.url at all (confirmed earlier in this document's own history:
GET on the client's attributes shows no such key). Every consuming application on this tier
inherits the same gap, because nothing tells any of them the IdP session ended. Read this as a
platform characteristic of the current federation config, not an app-specific bug, when a third
consumer turns up the same behavior - and expect a third consumer to turn up the same behavior,
since the mechanism has nothing app-specific about it.
Practical consequence stated plainly: on a shared/demo machine, a user who clicks Keycloak's
own "Sign out" (the exact scenario Keycloak's confirmation page itself advertises: "Stepping away
from a shared machine? Then signing out is the right call.") leaves every previously-authenticated
consuming app on that browser still fully usable by the next person to touch the keyboard. Whether
that is worth fixing with a real backchannel.logout.url (would need each consuming app to expose
a receiver TcSS/AWC could call, not proven to exist) is a decision for whoever owns this realm, not
something either investigating session should silently act on.
A second Keycloak login-form variant worth handling in any scripted login against this realm
EXERCISED 2026-08-25. This realm serves two visually different login forms depending on session state, and a login helper keyed on only one of them fails silently rather than loudly:
- Full form, after a genuinely clean sign-out: "Username or email" field plus "Password" field.
- Re-auth form, when the SSO session partially persists: username already shown ("
<user>Not you?"), text "Please re-authenticate to continue," and only a password field - no username field at all.
A helper that detects "already logged in" by the absence of a "Username or email" field will misread the re-auth form as success and submit nothing, leaving the browser parked on a password prompt while every downstream step fails against a page that is not the target application at all - which then reads as many unrelated broken checks rather than one unhandled login variant. "Forgot your password" text is present on BOTH forms and is the reliable signal to probe for "some Keycloak login form is showing," independent of which variant it is.
Related skills
tc-vm-operations, tc-deployment-center, tc-soa-session, tc-query-discovery,
diagnose-silent-failure.
Generated from skills/tc-sso-keycloak/SKILL.md in the tc-automation-skills library, which is the canonical copy and also serves as the agent skill set for Teamcenter work.