{"owner":"apereo","repo":"cas","hasSkills":true,"totalSkillsCount":32,"totalTokensCount":24300,"categories":["anthropic-skill","root-instruction","claude-rule","copilot-instructions","cursor-rule"],"hasMcp":true,"mcpConfig":{"mcpServers":{"cas":{"command":"npx","args":["-y","@modelcontextprotocol/server-cas"]}}},"found":[".claude/skills/cas-groovy-scripting/SKILL.md",".claude/skills/cas-handoff/SKILL.md",".claude/skills/cas-build-ci/SKILL.md",".claude/skills/cas-google-authenticator/SKILL.md",".claude/skills/cas-attribute-consent/SKILL.md",".claude/skills/cas-actuators/SKILL.md",".claude/skills/cas-docs-site/SKILL.md",".claude/skills/cas-import-pr/SKILL.md",".claude/skills/cas-interrupt-notifications/SKILL.md",".claude/skills/cas-ldap/SKILL.md",".claude/skills/cas-heimdall-authzen/SKILL.md",".claude/skills/cas-mcp-server/SKILL.md",".claude/skills/cas-oauth-oidc-authentication/SKILL.md",".claude/skills/cas-oauth-oidc/SKILL.md",".claude/skills/cas-oid4vc/SKILL.md",".claude/skills/cas-passwordless-webauthn/SKILL.md",".claude/skills/cas-protocol/SKILL.md",".claude/skills/cas-puppeteer-scenarios/SKILL.md",".claude/skills/cas-saml/SKILL.md",".claude/skills/cas-sandbox-verification/SKILL.md",".claude/skills/cas-service-registries/SKILL.md",".claude/skills/cas-session-stores/SKILL.md",".claude/skills/cas-startup-performance/SKILL.md",".claude/skills/cas-storage-backends/SKILL.md",".claude/skills/cas-testing/SKILL.md",".claude/skills/cas-ticket-registries/SKILL.md",".claude/skills/cas-ui/SKILL.md",".claude/skills/cas-web-security/SKILL.md","AGENTS.md","CLAUDE.md",".github/copilot-instructions.md",".cursorrules (testrail-test-case-cursorrules-prompt-file.mdc)"],"skills":{".claude/skills/cas-groovy-scripting/SKILL.md":"---\nname: cas-groovy-scripting\ndescription: \"Review and change Groovy scripting support (core/cas-server-core-scripting): ScriptResourceCacheManager, CompiledGroovyScript, inline vs external scripts, compiling off the request path, script security. Use when touching Groovy scripts or any module that runs them.\"\n---\n\n# Groovy and scripting\n\n## Groovy and scripting\n\n- Start at `core/cas-server-core-scripting`; roughly fifty modules delegate to the same five classes, so\n  changes there are changes to authentication, attribute release, MFA, AUP, OIDC, SAML and themes at once.\n- `ScriptResourceCacheManager` is a singleton whose `close()` clears the entire cache — it must never be\n  used in a try-with-resources block.\n- `CompiledGroovyScript.of` decides how a script runs: a stateless script (only final fields holding immutable\n  values) gets a fresh instance per call and runs in parallel; a stateful one keeps a shared instance serialized\n  on a blocking lock, on purpose, so its fields stay consistent. Do not report that lock as a bottleneck. Review every script-backed\n  decision for what a `null` result means and prefer fail-closed defaults at security boundaries — MFA\n  triggers and AUP read `null` as \"skip\". Never guard script execution with a timed `tryLock`; a timeout\n  that returns `null` is indistinguishable from a script that declined.\n- Do not compile scripts on a request path (`fromScript`/`fromResource` per call). Go through\n  `resolveScriptableResource`, or hold the compiled script in a transient field, and remember `fromResource`\n  also starts a file-watcher thread that nothing closes. The cache manager comes from\n  `ApplicationContextProvider` and is absent outside Spring, so always fall back to the factory instead of\n  throwing, or you will break components and tests that are constructed directly.\n- Inline (`groovy { ... }`) and external (`file:`/`classpath:`) scripts behave differently: only external\n  scripts honour `failOnError` and support reload, and only inline scripts support bindings (the\n  `setBinding` interface default is a no-op). Inline scripts swallow `GroovyRuntimeException`. Test both\n  forms, and cover bindings with concurrent and pooled-thread tests — `GroovyShellScriptTests.BindingTests`\n  is the pattern.\n- Treat compiling a Groovy script as running it: AST transformations execute inside the compiler. Script\n  text that comes from anywhere other than deployment configuration must be parsed\n  (`ScriptingUtils.validateGroovyScript`), never compiled.\n- Puppeteer coverage lives in the 18 `ci/tests/puppeteer/scenarios/*groovy*` scenarios\n  (`service-access-strategy-groovy`, `surrogate-login-groovy`, `mfa-provider-selection-trigger-groovy`,\n  `interrupt-afterauthn-groovy`, `webflow-groovy-action`, …). None of them exercise concurrency, script\n  errors, or cache expiry, so a change in this area needs new coverage rather than a rerun.\n",".claude/skills/cas-handoff/SKILL.md":"---\nname: cas-handoff\ndescription: \"Finish and hand off a change in the CAS repository: Checkstyle on changed Java files, no stale .git/index.lock, release-notes and puppeteer cross-checks, and the ready-to-use commit message. Use at the end of every task that changed files in cas-server, before reporting the work as done.\"\n---\n\n# Handing off CAS work\n\nRun these steps, in order, before reporting the work as done. The rules behind them follow.\n\n1. Compile what changed, main and test sources, with Error Prone, each changed module on its own class path so a\n   missing test dependency shows (see Conventions in AGENTS.md, the `cas-testing` skill and, in a sandbox without\n   Gradle, the `cas-sandbox-verification` skill).\n2. Run Checkstyle on every changed Java file (rule below), and fix overloads, imports and file length it reports.\n3. Run `git --no-optional-locks diff --check`.\n4. Cross-check the puppeteer scenarios that cover the change and update them (`cas-puppeteer-scenarios` skill).\n5. Add the release-notes entry when the work calls for one (security notes: see \"Test, concurrency, and hand-off\n   expectations\" in AGENTS.md; release-notes format: `cas-docs-site` skill).\n6. Record new, durable lessons in AGENTS.md or in the matching skill under `.claude/skills/`.\n7. Check `ls .git/*.lock` and clear what is left.\n8. End with the commit message (rule below). Say clearly what was not run.\n\n## Test, concurrency, and hand-off expectations\n\n- When the work is done, end the hand-off with a ready-to-use commit message covering only the uncommitted changes\n  (`git --no-optional-locks status`): a conventional subject (`fix:`, `feat:`, ... as in `git log`) and a short body.\n  The maintainer commits; do not commit yourself.\n\n## Environment limits\n\n- Leave no `.git/index.lock` behind. `git status` and `git diff` take that lock to refresh the index, and in a remote environment that cannot delete files the lock survives the command and blocks every subsequent git operation the maintainer runs. Read git state with `git --no-optional-locks status` / `git --no-optional-locks diff`, which never takes it, and before finishing check `ls .git/*.lock` and clear anything left. If deletion is refused, request it rather than leaving the repository wedged.\n- Maintainer: run Checkstyle on every changed Java file before handing work off, even without Gradle. Use the version in\n  `gradle/libs.versions.toml` (`checkstyle-<version>-all.jar` from the Checkstyle GitHub releases; 14.x needs Java 21+) as\n  `java -Dcheckstyle.suppressions.file=style/checkstyle-suppressions.xml -Dcheckstyle.importcontrol.file=style/import-control.xml\n  -jar checkstyle.jar -c style/checkstyle-rules.xml <files>`. When the workspace JDK is older, tar the files and `style/` into\n  `build/` (deletable without asking) and run it where Java 21 is available.\n",".claude/skills/cas-build-ci/SKILL.md":"---\nname: cas-build-ci\ndescription: \"Gradle build settings (configuration cache, Isolated Projects, build cache, test-category discovery) and the GitHub Actions workflows (tests.yml coverage job, setup-gradle caching, daemon reuse, CodeQL). Use when changing build logic under buildSrc or gradle/, the root Gradle settings, or anything under .github/workflows or ci/.\"\n---\n\n# CAS build and CI workflows\n\n## Workflows that matter here\n\n- Gradle 9.8.0 enables build caching, the configuration cache, parallel execution and Isolated Projects here.\n  Isolated Projects configures every project on a cache miss and ignores configuration on demand. Compare representative\n  scoped commands before changing either setting. User-home `gradle.properties` overrides the repository's JVM settings.\n- Test category discovery runs inside `TestCategoryTagsValueSource`: only its sorted tag list is a configuration input.\n  Implementation edits retain the cached graph; adding/removing categories invalidates it. Do not move source reads back\n  into the convention script or restore the old `.gradle/cas-test-tags` fingerprint cache.\n- An IDE `JetGradlePlugin` error about `setExcludedTaskNames` comes from the IDE integration, not category discovery.\n  Use `--no-isolated-projects` for the affected IDE invocation until that integration supports Isolated Projects; keep\n  configuration caching enabled and do not disable Isolated Projects globally to hide an IDE-only violation.\n\n## Practical boundaries\n\n- Unit-test matrix jobs (`tests.yml`) record JaCoCo data only (`testcas.sh --with-coverage` only turns on the agent) and upload the `.exec` files; the single `coverage` job builds `jacocoRootReport` and does every Sonar/Codecov/Coveralls/Codacy upload. Do not move `jacocoRootReport` or `sonar` back into the matrix: the root report compiles all ~430 projects and Sonar re-analyses the whole code base, which is what made each category job 17 minutes instead of 7.\n- Every `gradle/actions/setup-gradle` step sets `gradle-home-cache-excludes: caches/build-cache-1` and\n  `cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}`. Preserve both: the local build cache duplicates Develocity\n  outputs, and the encryption key enables saving/restoring the project configuration cache. Fork PRs without this secret\n  can still build, but cannot restore the encrypted configuration cache. Do not layer `setup-java` or GraalVM Gradle\n  caching over `setup-gradle` in the same job.\n- CI permits Gradle daemon reuse within a job; `ci/init-build.sh` must not append a disabling user-home property.\n  Release, documentation and scenario scripts preserve daemon reuse across their Gradle invocations. CodeQL keeps\n  `--no-daemon` for compiler tracing. Test categories and Puppeteer scenarios are fetched once per discovery\n  job; print the captured result and split scenario JSON with `jq`, preserving the existing matrix boundaries.\n- The CodeQL job (`analysis.yml`) applies `--no-build-cache --rerun-tasks` after its shared build options so traced\n  compilation actually runs. The current `settings.gradle` has no CodeQL-specific `JavaCompile` cache guard. Preserve\n  these workflow flags: CodeQL only extracts compilations it observes, so restored or up-to-date outputs empty the scan.\n",".claude/skills/cas-google-authenticator/SKILL.md":"---\nname: cas-google-authenticator\ndescription: \"Review and change Google Authenticator (GAuth) MFA credential repositories across backends (JPA, Redis, Mongo, DynamoDB, LDAP, JSON, REST, in-memory): owner-scoped deletes, encoding, counts, verification flags, the Redis index, crypto. Use when touching support/cas-server-support-gauth*.\"\n---\n\n# Google Authenticator MFA repositories\n\n## Google Authenticator credential repositories (all backends)\n\n- Review every backend against the same contract, with crypto on: `cas.authn.mfa.gauth.crypto.enabled` defaults to `true`, but every puppeteer scenario and the repository tests turn it off, so undecoded returns and double encryption never show up in CI.\n- A device delete must be scoped to its owner. WebAuthn, YubiKey and Duo device managers delete by username and id; `OneTimeTokenCredentialDeviceManager` and `GoogleAuthenticatorDeleteAccountAction` delete by id alone, and ids are guessable (JPA sequence; `currentTimeMillis` before 42bcde1).\n- `BaseOneTimeTokenCredentialRepository.encode()` mutates its argument. Never pass an object you will keep or save again; JPA `get(id)` and InMemory `get(id)` return stored, undecoded instances, and updating them double-encrypts.\n- `count()` means devices in Redis, JPA, Mongo and DynamoDB, but users in InMemory, JSON and LDAP; check which a caller assumes.\n- A repository read that fails must not look like \"no devices\": `OneTimeTokenAccountCheckRegistrationAction` sends empty or null results to enrollment (REST returns null, JSON returns empty on errors).\n- When testing deletes or updates, store devices for two users and two devices per user, and assert the others survive; single-user tests hid the DynamoDB `GE` filter and the LDAP update bug.\n- To make a range-filter bug visible in a DynamoDB delete test, pick keys that a wrong comparison would sweep up: the kept user's name sorts after the deleted one (\"z…\" vs \"a…\"), and the kept device ids are larger than the deleted id. Random UUIDs alone pass by luck half the time.\n- DynamoDB tables keyed by `id` should delete with `DeleteItem` on that key, not a scan followed by deletes.\n- Webflow actions and device managers that take a device id from the request must load it with the owner-scoped `get(principal.getId(), id)` and act only on what that returns. Unscoped `get(id)` / `delete(id)` are for the actuator and internal paths.\n- Every repository read returns a decoded copy (JPA detaches before decoding); `update()` must persist `properties`, which the verification flags of the delete and confirm actions live in.\n- The delete/confirm verification flags are stored on the account as `<flag>:<epoch millis>` and expire (`GoogleAuthenticatorAccountVerificationUtils`). They cannot live in flow or conversation scope: the login webflow keeps its execution state client-side, and the AJAX check and the form submit carry different execution keys.\n- `CasReentrantLock.tryLock(supplier)` returns null when the lock is not acquired; never let that null reach a caller as \"no accounts\".\n- `BaseOneTimeTokenCredentialRepository.encode()` returns an encoded copy and leaves its argument alone; store and return the copy, and read normalized values (lowercased username) from it, not from the argument.\n- Per-tenant ciphers must be built once and reused: a cipher built from settings with blank keys generates its own keys, so a new instance per call cannot decode what the previous one encoded. `BaseOneTimeTokenCredentialRepository` caches them by tenant and crypto settings.\n- WebAuthn FIDO metadata: `FidoMetadataDownloader.refreshBlob()` always downloads; `loadCachedBlob()` uses the cache until the BLOB's `nextUpdate`. CAS refreshes at startup and falls back to the cache on `IOException` (the FIDO service rate-limits with HTTP 429). `CompositeAttestationTrustSourceTests` downloads from the real service; a 429 there with no cache from an earlier run is an outage, not a regression.\n- Active Directory keeps a single value of `description` on user objects. The LDAP GAuth repository therefore writes all of an entry's accounts as one JSON array value; writing one value per account loses all but one device on AD.\n- A webflow action that validates a GAuth token and then updates the device must read the device again after validation: the validator updates its own copy (used scratch code, last-used time), and updating an older copy undoes that.\n\n## Google Authenticator Redis repository\n\n- Accounts live twice: the record at `CAS_TOKEN_ACCOUNT:<id>` and a copy as a member of the set `CAS_TOKEN_PRINCIPAL:<username>`. `get(username)`, `count(username)` and OTP validation read the set, not the record, so a delete that removes only the record leaves a device that is still listed and still validates.\n- Reads must not repair the index. A read that removes set members whose record \"does not exist\" acts on whatever the read saw; with `read-from` sending reads to replicas, and the record and the set on different cluster slots, a lagging replica makes a live device look orphaned and the removal on the primary is permanent. Repair belongs on the explicit `delete(id)`: when the record is missing the owner is unknown, so it scans `CAS_TOKEN_PRINCIPAL:*` in batches and removes members with that id.\n- Remove set members by the raw bytes Redis returned (`executePipelined(..., RedisSerializer.byteArray())` then `sRem`), not by re-serializing a deserialized account; the value serializer is LZ4 over JDK serialization, and a removal only matches if the bytes are identical.\n- Spring Data Redis pipelining works on Lettuce cluster connections (`LettuceClusterConnection` does not override `openPipeline`) and key `SCAN` spans all nodes, so pipelines and scans here are cluster-safe.\n",".claude/skills/cas-attribute-consent/SKILL.md":"---\nname: cas-attribute-consent\ndescription: \"Review and change attribute consent: ConsentDecision, the consent webflow gate, ConsentQueryResult, repositories and their lookups and deletes, decision decryption, dates and the consent scenarios. Use when touching support/cas-server-support-consent*.\"\n---\n\n# Attribute consent\n\n## Attribute consent review discipline\n\n- Start at `ConsentDecision`, not at a repository. `id` is a primitive `long` with\n  `@GeneratedValue`, so only JPA generates one. That single field is the key in four\n  backends: LDAP merges by it, Redis keys on `ConsentDecision:<principal>:<id>`, Mongo maps\n  it to `_id` by naming convention, and DynamoDB uses it as the table HASH key. It used to\n  stay `0` on every one of them, which collapsed each user (LDAP, Redis) or the whole\n  deployment (Mongo, DynamoDB) onto a single decision. `DefaultConsentDecisionBuilder.build`\n  now assigns a random positive id, JPA still replaces it with a generated one, and LDAP's\n  `mergeDecision` assigns one when `id <= 0`. Anything that creates a `ConsentDecision`\n  outside the builder -- the actuator's `/import`, say -- still arrives with id `0`.\n- `BaseConsentRepositoryTests` used to call `decision.setId(1/100/200)` before every store,\n  which is why the id defect never failed a test. `verifyMultipleDecisionsForPrincipal` now\n  stores builder-produced decisions for two users across two services and then deletes by id\n  and by principal, against every backend; `getOtherUser()` is overridden where the second\n  user must already exist (LDAP). The class holds a `@ResourceLock` because\n  `verifyDeleteRecordsForPrincipal` calls `deleteAll()` on the shared repository.\n- Consent is a gate in the login webflow, not a filter at attribute release.\n  `ConsentWebflowConfigurer` prepends `CheckConsentRequiredAction` to the login flow's\n  `generateServiceTicket` state; the only other consumer is\n  `SamlIdPConsentSingleSignOnParticipationStrategy`. Nothing in\n  `RegisteredServiceAttributeReleasePolicy` reads a stored decision, so the consented set\n  never constrains what is released, and any path that issues without the login flow --\n  REST protocol tickets, proxy tickets, OIDC refresh/userinfo, the OpenID4VCI\n  pre-authorized code grant -- releases attributes with no consent check at all.\n- Follow `ConsentQueryResult`. It carries the decision, the service and now the resolved\n  consentable attributes, and it used to be thrown away: `ConsentActivationStrategy` returned\n  a bare boolean and `CheckConsentRequiredAction` reduced it to an event, so\n  `prepareConsentForRequestContext` repeated the full `getConsentableAttributes` evaluation\n  (person directory included) and the repository read in the same request. The strategy now\n  returns the result and the action hands it to `prepareConsentForRequestContext`, which\n  resolves nothing when the result carries attributes -- a result built by a custom strategy\n  carries none and still falls back to resolving. What remains per consent interaction is one\n  evaluation and read for the prompt, one of each inside `storeConsentDecision` on confirm,\n  and one more from the `generateServiceTicketAfterConsent` clone that re-runs the check;\n  `getRegisteredServiceForConsent`, and with it the access strategy, still runs twice per\n  prompt. Count these by request before claiming a number.\n- `DefaultConsentEngine.executeRepositoryOperation` calls `toConsentRepository(tenant)` on\n  every operation, and `TenantJdbcConsentRepositoryBuilder` builds a DataSource plus a\n  Hibernate `EntityManagerFactory` inside it, then destroys them. Check the multitenant\n  path before judging consent performance; the single-tenant path is not representative.\n- Repository lookups must be checked for operator and pattern correctness, not just for\n  the right column names. `DynamoDbConsentFacilitator` matched SERVICE and ID with\n  `ComparisonOperator.GE` (correct for the date ranges other CAS DynamoDB facilitators\n  use it for, wrong for an equality lookup); it now uses `EQ`, but every read is still a\n  full `scanPaginator` because `id` is the table's only key. `RedisConsentRepository`\n  interpolated the raw principal into a SCAN glob; it now escapes glob characters and keeps\n  only keys whose remainder is a numeric id, because the `:` delimiter alone lets principal\n  `alice` reach the decisions of `alice:x`. Deleting one decision there is a `DEL` of the\n  exact key rather than a scan.\n- Deletes deserve the same reading as reads. `JpaConsentRepository.deleteConsentDecisions`\n  used `getSingleResult()` and so silently deleted nothing for any user with more than one\n  decision; it is a bulk JPQL delete now. `ChainingConsentRepository` -- which is what the\n  `consentRepository` bean always is, wrapping every configured store -- stored to every\n  repository but deleted with `anyMatch`, stopping at the first success; it now asks every\n  repository. `RestfulConsentRepository` sent `id` where the documented contract and the\n  test's own mock server say `decisionId`, so a conforming server read a single revocation\n  as \"revoke everything\". Revocation failing quietly is worse than consent failing loudly.\n- A decision that will not decipher used to be fatal: `getConsentableAttributesFrom` turned any\n  failure into `IllegalArgumentException`, which propagated out of `isConsentRequiredFor` and out\n  of the login flow, so key rotation or a node without the shared `cas.consent.core.crypto` keys\n  locked the user out. It now logs and returns no attributes, which reads as a mismatch and\n  re-prompts; the new decision is written back with the current keys. Weigh every failure on this\n  path the same way -- the login flow has no recovery for an exception thrown from the consent\n  check, so the question is always whether re-prompting is safe, not whether the error is correct.\n- `ConfirmConsentAction` parsed `option`, `reminder` and `reminderTimeUnit` straight off the request\n  with `Integer.parseInt` / `Long.parseLong` / `ChronoUnit.valueOf` and persisted them unvalidated, so\n  a missing parameter was a 500 on the confirm POST and `ChronoUnit.FOREVER` or `ERAS` parsed fine and\n  then threw from `createdDate.plus(...)` on every later login. Each value now falls back on the\n  configured default, and the pair is rejected unless it can actually be applied to a date -- checked by\n  applying it, since `LocalDateTime.isSupported` accepts `ERAS` and only the ISO era range rejects it.\n  `DefaultConsentEngine.calculateReminderExpirationDate` is the second half of that: a stored reminder\n  that cannot produce a date means consent is required, never an exception, which is what protects\n  decisions that were written before this validation existed or through the actuator's import.\n- `ConsentDecision.createdDate` is a zone-less `LocalDateTime` that one node writes and another compares\n  against its own clock, so it is produced with `ZoneOffset.UTC`, not `ZoneId.systemDefault()`. Before that,\n  the backends disagreed with each other: JPA, Redis, LDAP, DynamoDB and REST keep the wall-clock value\n  verbatim, so a node in another zone read a decision as hours older or newer, while MongoDB was accidentally\n  correct because Spring Data converts `LocalDateTime` through the system zone in both directions and so\n  normalizes to the instant. The reminder units offered in the consent view go down to seconds, so that skew\n  is not academic. `OneTimeToken.issuedDateTime` and the Google Authenticator token repositories still carry\n  the same pattern with second-scale windows.\n- `cas.consent.core.active` defaults to `true`, so adding the module turns consent on for\n  every service that releases attributes. Weigh findings about the consent path as if it\n  is always on.\n- Wallet/OID4VCI integration is the real gap, not a defect in the consent modules:\n  `BaseOidcVerifiableCredentialEncoder` reads `principal.getAttributes()` directly against\n  the issuer's configured claim list, so a credential handed to a wallet carries no\n  consent record, no per-claim choice, and no revocation. Selective disclosure exists in\n  the SD-JWT encoder but the holder never chooses what is disclosed at issuance time.\n- Puppeteer coverage: `attribute-consent` (in-memory repository, one user, one service),\n  `multitenancy-consent-jdbc`, `saml2-idp-login-consent`, `saml2-idp-login-consent-with-sso`.\n  No scenario exercises LDAP, Redis, Mongo, DynamoDB or REST consent, and none stores two\n  decisions for the same user, which is exactly the shape every storage defect above needs.\n",".claude/skills/cas-actuators/SKILL.md":"---\nname: cas-actuators\ndescription: \"Review and change CAS actuator endpoints: the security filter chain, access levels and IP access, REST actuator endpoints, exposure vs access, unmasked responses, request-path regexes, auditing and the actuator scenarios. Use when touching actuator endpoints or management security.\"\n---\n\n# Actuator endpoints\n\n## Actuator endpoints review discipline\n\n- There are ~63 endpoints. Inventory them with\n  `grep -rl --include=*.java -E \"@(RestControllerEndpoint|ControllerEndpoint|Endpoint|WebEndpoint)\\(id\"`;\n  `support/cas-server-support-reports-core` owns about a third, the rest are scattered across\n  feature modules. Review them as one attack surface, not one module at a time.\n- The whole security boundary is a single `SecurityFilterChain` built by\n  `CasWebSecurityConfigurerAdapter.configureHttpSecurity`. Rules are added in order --\n  ignored patterns (permitAll) -> each `CasWebSecurityConfigurer.configure` -> per-endpoint\n  `cas.monitor.endpoints.endpoint.*` -> `configureEndpointAccessToDenyUndefined` -> static\n  resources -> form login -- and Spring Security takes the first match, so anything a\n  `getIgnoredEndpoints` contributes wins over every endpoint rule that follows.\n- `prepareProtocolEndpoint` emits `/<ep>**` as well as `/<ep>/**`. The first form is a\n  within-segment wildcard, so an ignored endpoint also permits sibling paths sharing its\n  prefix. Check that before adding an entry.\n- That chain disables security headers and logout globally, and CSRF everywhere except the paths that configurers\n  declare through `getCsrfProtectedEndpoints()` (CIBA verification, WebAuthn). It is the same chain that serves the\n  actuator. Any browser-reachable session credential (`cas.monitor.endpoints.form-login-enabled`) therefore leaves\n  mutating actuator operations unprotected against cross-site requests.\n- `ActuatorEndpointProperties.access` defaults to `DENY` and the `endpoint` map always\n  carries a synthetic `defaults` key. The adapter iterates the map and calls\n  `EndpointRequest.to(\"defaults\")`, which resolves to no path and silently registers nothing;\n  the real default comes from `configureEndpointAccessToDenyUndefined`. Do not read the loop\n  as the place defaults are enforced.\n- `configureEndpointAccessToDenyUndefined` reads `management.endpoint.<rootPath>.access` with\n  a raw `Environment.getProperty` call and maps `UNRESTRICTED`/`READ_ONLY` to `permitAll`.\n  Relaxed spellings of that key are not seen (fail-closed), but the canonical spelling turns a\n  Boot-level exposure decision into anonymous Spring Security access.\n- `IP_ADDRESS` access is evaluated by `IpAddressAuthorizationManager`, which reads\n  `cas.audit.engine.alternate-client-addr-header-name` (default `X-Forwarded-For`) straight off the\n  request. This is intentional and is NOT a CAS defect: the fronting proxy, load balancer and\n  external boot configuration are expected to set that header as a trusted header, or to blank it\n  out and let Tomcat's own valves resolve the client address. Configuring that correctly is left to\n  the deployer, so do not report header spoofing here as a finding.\n- `BaseCasRestActuatorEndpoint` endpoints are NOT Spring Boot operations. They are discovered by\n  CAS's own `RestActuatorEndpointDiscoverer`, mapped by `RestActuatorEndpointHandlerMapping`, and\n  their handlers are Spring MVC mappings rather than `@ReadOperation`/`@WriteOperation` methods.\n  Boot applies an access level by filtering operations, and these endpoints declare none, so\n  `management.endpoint.<id>.access=READ_ONLY` does not stop their `@PostMapping`, `@PutMapping`,\n  `@PatchMapping` or `@DeleteMapping` handlers the way it stops a Boot write operation. That is\n  the gap `RestActuatorEndpointHandlerMapping.isMappingPermittedByAccessLevel` closes: it resolves\n  the endpoint's access through the `EndpointAccessResolver` bean and applies the same rules Boot\n  applies to operations, registering nothing under `NONE` and only `GET`/`HEAD`/`OPTIONS` mappings\n  under `READ_ONLY`. Note that `configureEndpointAccessToDenyUndefined` maps `READ_ONLY` to\n  `permitAll`, which is why the write methods used to get through, and maps `NONE` to `denyAll`, so\n  a `NONE` endpoint is usually refused by security before the missing mapping matters.\n  `ActuatorEndpointAccessControlTests` covers all of it, with `releaseAttributes` beside\n  `registeredServices` as the Boot operation endpoint that has always behaved this way.\n- Before changing what an access level does to these endpoints, measure the blast radius rather\n  than reasoning from the annotation: every REST actuator endpoint declares\n  `defaultAccess = Access.NONE` and the web application ships `management.endpoints.access.default=NONE`,\n  which suggests widespread breakage, but in fact all 44 test classes and all 69 puppeteer scenarios\n  that call one already declare `management.endpoint.<id>.access` or `management.endpoints.access.default`\n  explicitly. Walk the test tree and `ci/tests/puppeteer/scenarios/*/script.json` and count.\n- Do not confuse that with exposure. A REST actuator endpoint left out of\n  `management.endpoints.web.exposure.include` answers `403`, not `200`: no rule is registered for\n  it in `configureEndpointAccessToDenyUndefined`, and Spring Security denies a request no matcher\n  claims. Verify the status a given combination of exposure and access actually produces before\n  describing any of it as reachable.\n- When `management.server.port` differs, `CasCoreWebManagementContextConfiguration` registers the\n  handler mappings in the child management context but no `SecurityFilterChain`. Verify what\n  actually secures that port before assuming the main chain applies.\n- Several endpoints are authentication primitives, not diagnostics: `casValidate`,\n  `samlValidate` and `samlPostProfileResponse` mint CAS assertions and signed SAML responses\n  for an arbitrary username, and the `password` parameter is optional -- with no password they\n  fall through to `PrincipalResolver` and succeed. Anything that widens actuator access widens\n  impersonation. `tokenAuth` only validates a JWT and answers the principal with the service's\n  id, name and service id; it no longer issues tokens, and must never return service properties.\n- These endpoints return configuration values and service definitions unmasked, unlike Boot's own\n  `/env` and `/configprops`. `casConfig` `/retrieve` returns raw property values and\n  `registeredServices` serializes `OAuthRegisteredService.clientSecrets` (the `value` field carries\n  no `@JsonIgnore`). Masking was proposed and rejected, so do not re-propose it without agreeing\n  the approach with the maintainer first. `casConfig` `/encrypt` and `/decrypt` are a deliberate\n  operator tool that Palantir exposes in its UI, not an oracle to close.\n- Before proposing masking on any actuator response, work out how the admin UI consumes it: grep\n  `support/cas-server-support-thymeleaf/src/main/resources/static/js/palantir-*.js`. Palantir's\n  service editor does a full `GET /registeredServices/{id}` -> edit -> `PUT` round-trip\n  (`palantir-services.js`), so masking any path it reads would write the mask back into the\n  registry, and `/export` has the same requirement; redacting there needs the save path to preserve\n  existing secrets first. `casConfig` `/retrieve` does not have that problem, because\n  `palantir-pac4j.js` reads only `name` and `propertySource` from it.\n- User-supplied strings are compiled into regular expressions on the request path:\n  `configurationMetadata/{term}` (then matched over every known property in a `parallelStream`)\n  and `casConfig` `/retrieve`. Treat these as ReDoS/CPU-exhaustion boundaries; actuator endpoints\n  are not covered by CAS's authentication throttling.\n- No actuator endpoint carries `@Audit`. Session destruction, service deletion, key rotation,\n  configuration mutation and token minting all happen without an Inspektr record. Check for this\n  before claiming an administrative action is traceable.\n- Puppeteer coverage: `actuator-endpoint-cors`, `actuator-endpoint-login-jdbc`,\n  `actuator-endpoint-management`, `actuator-endpoint-metrics`, `actuator-endpoint-reports`,\n  `actuator-endpoint-roles`, `actuator-form-login`, `actuator-form-login-ldap`,\n  `actuator-form-login-ldap-roles`, `actuator-form-login-ldap-userauthz`,\n  `configuration-refresh-endpoint`, `sso-sessions-endpoint`, `tomcat-forwarded-headers`\n  (the only `IP_ADDRESS` scenario). Every one of them asserts that an *authorized* caller gets\n  200; none asserts 401/403 for an unauthenticated one, and 158 scenarios run with\n  `management.endpoints.web.exposure.include=*`. A change to endpoint security therefore needs a\n  new negative scenario rather than a rerun.\n",".claude/skills/cas-docs-site/SKILL.md":"---\nname: cas-docs-site\ndescription: \"Edit the CAS documentation site (docs/cas-server-documentation, Jekyll plugins, site.js/site.css, ci/docs/publish.sh, the docs data generator) and release notes: page titles and descriptions, setting links, settings rules, release-notes front matter and tags, Quick Start wizard, sidebar, mobile layout. Use when editing any documentation page or release_notes/RC*.md.\"\n---\n\n# CAS documentation site\n\n## Practical boundaries\n\n- Documentation pages on gh-pages are pre-rendered per version, but they load the stylesheet and script from the shared site root, and every publish overwrites those root files. The current design ships as `stylesheets/site.css`, `stylesheets/site-print.css` and `javascripts/site.js`; the root `stylesheet.css`, `print.css` and `main.js` belong to the released versions' old markup. A redesign that changes page markup must use new asset names, never replace the ones older versions link.\n- The documentation data generator (`docs/cas-server-documentation-processor`) runs as `java @build/casdocsgen.args ...`, an argument file written by its `docsGeneratorArguments` task from `sourceSets.main.runtimeClasspath`; `publish.sh` no longer builds the ~1 GB `casdocsgen.jar` boot jar. Its Gradle run adds `-DskipErrorProneCompiler=true` (override with `DOCS_GENERATOR_GRADLE_OPTIONS`, an empty value restores Error Prone). It still needs every CAS module compiled: actuators, feature toggles and shell commands are found by ClassGraph scans of the runtime classpath, and third-party settings come from the dependencies' metadata. Those lookups go through `CasDocumentationClassIndex`, one ClassGraph scan of `org` shared by the exporters; add new class lookups there rather than calling `ReflectionUtils`, which scans the whole classpath on every call.\n- `ci/docs/publish.sh` exit codes carry meaning for the workflow retry (`retry_on_exit_code: 1`): 1 is a failure worth another attempt, 3 is broken internal links/images/scripts (fails at once, nothing is published), 4 is broken external links only (the site is published first, then the job fails). Keep new failure paths on 1 unless a rerun cannot help. External links are checked only on the weekly schedule or when asked (`--proof-external`, the `proofReadExternal` input or `vars.DOCS_PROOF_EXTERNAL`), with successful results cached for 7 days in `build/htmlproofer`.\n- Actuator endpoint blocks (`_includes_site/actuators.html`) take their operations from the `cas_actuator_operations` filter in `_plugins/cas_actuators.rb` (sorted, parameters normalized, curl built there), and settings snippets from `cas_actuator_enable_snippet` / `cas_actuator_security_snippet` via `cas-actuator-snippet.html` in all three formats. Per operation, render only what differs (parameters, response, example); setup, security, settings and troubleshooting are rendered once per include in the shared tabs. The operations table is excluded from `responsiveTables()`.\n- Settings search: `ci/docs/index.js` writes `assets/data/<version>/index.json` as `{generated, docs}` (no lunr index; each doc carries name, short type, description, default, kind required/optional/thirdparty, module, duration, deprecation). `site.js` searches it directly (`searchCasSettings`, shared by the Configuration Properties page and the Shift-Shift palette); the page state lives in the URL (`?q=&exact=1&scope=name&kind=cas|thirdparty&deprecated=hide`). The layout exposes `data-docs-base`, `data-docs-version` and `data-docs-build` on `<body>` for these URLs.\n- Feature toggles (`Configuration-Feature-Toggles.md`) render through `_includes_site/cas-feature-toggles.html` from the `cas_feature_catalog` filter in `_plugins/cas_features.rb`, which derives feature and module from the `CasFeatureModule.<Feature>[.<module>].enabled` property, groups features by area and links docs only when the target page exists for that version. Add title overrides, groups or docs links there, not in the include. The environment variable form maps both `.` and `-` to `_` (`CasFeatureEnabledCondition` reads through `Environment.getProperty`), unlike the relaxed-binding form used for regular settings.\n- Module dependency tabs (`_includes_site/casmodule.html`, included through `include_cached`) are plain Bootstrap tabs: no inline script, the default tab and pane are chosen together in Liquid, and pane ids use the full coordinates plus the variant options. Do not add per-include scripts or truncated ids; either one lets the selected tab and the visible pane drift apart.\n- Local serving (`publish.sh --serve true`) keeps the configured `baseurl: /cas`, so the site answers at `http://localhost:4000/cas/<version>/` exactly like production. `variables.html` always sets `basePath` to `/cas` and `site.js` has no localhost path branches; do not reintroduce `--baseurl \"\"` or localhost-specific paths.\n- Release notes (`release_notes/RC<n>.md`) carry a `release:` front matter block (summary, facts, upgrade notes, highlights, optional spotlight over a front matter image list) and open with `{% include release-digest.html %}` and end with `{% include release-footer.html %}`. Tag New & Noteworthy topics with a block IAL on the line after the heading and Other Stuff bullets with a list item IAL (`- {: .fixed} ...`): types are `new`, `changed`, `fixed`, `action`, `removed`; add `data-area=\"...\"` only when the first link does not identify the area. The area must be a key of `AREAS` in `_plugins/cas_release_notes.rb` (SSO cookies and sessions are `tickets`; there is no `sso`). `_plugins/cas_release_notes.rb` normalizes the tags, groups Other Stuff by area and resolves `section:` names to kramdown GFM heading ids before rendering; a `section:` that names no heading logs a warning. Upgrade notes may point at an Other Stuff area group by its label. `release_notes/RC.md` (front matter `release_aggregate:`) is generated from every RC page in the same folder: topics with the same title are merged and show each release candidate's notes, identical Other Stuff items are listed once with all their release candidates, and the `<!-- release-changes -->` marker is where the generated changes go. Never edit changes into that page by hand.\n- Section heading icons come from `CAS_SECTION_ICONS` in `site.js` and apply only to direct `h2` children of the article whose id is in that map; add an entry there rather than icons in markdown.\n- In the development docs, `site.js` wraps every table inside `#cas-docs-container` in a framed `.table-scroll` card, so do not use `<table>` for layout inside components (buttons, headers, badges); use flex markup instead.\n- Documentation property blocks get their settings from the `cas_properties` / `cas_third_party_properties` filters in `docs/cas-server-documentation/_plugins/cas_properties.rb`; do not loop over `site.data` in Liquid for this, it scans ~14k entries per block. Liquid `assign` inside an include writes to the page scope, so a `casproperties` include nested inside another block's panels must pass `topics=\"false\"` (and usually `intro=\"false\"`) or it resets the outer block's state. Their output is wrapped in `{::nomarkdown}`, so include them at the start of a line in markdown or pass the capture through `markdownify` (with `|`, not `||`). Catalog descriptions carry raw `<`/`>` (for example `management.endpoint.<id>.access`); the plugin escapes every tag outside `INLINE_TAGS` into `descriptionHtml` / `summaryText`, so print those fields and never unescape descriptions in Liquid: one stray tag inside the actuator modal makes kramdown drop the closing tags and everything after it on the page.\n- Documentation page titles must be unique: use `title: CAS - <page H1>`, and give overview pages a `description:`\n  front matter entry (under 160 characters). Pages without one get their meta description from the first paragraph in\n  `_layouts/default.html`, which skips dependency boilerplate, short or colon-ended lead-ins and \"see this guide\" lines.\n- The Versions menu lists `documentation_versions` from `docs/cas-server-documentation/_config.yml` (development plus\n  maintained releases); update it together with the EOL schedule in `developer/Maintenance-Policy.md`.\n- Third-party CSS and JS in `_layouts/default.html` are pinned to exact versions with `integrity` (sha384) and\n  `crossorigin=\"anonymous\"`. Recompute the hash whenever a version changes; Google Fonts and the gtag script cannot carry one.\n  Analytics is the GA4 tag `G-W3M8JXP7GP`, loaded only on `apereo.github.io`.\n- Do not dim muted text with `opacity`: `--docs-muted` on its own meets 4.5:1 in both themes, and opacity drops it below.\n  `--docs-amber` is darker in light mode for the same reason.\n- In `casmodule.html`, only the tab links sit inside `role=\"tablist\"`; the Resources dropdown is a sibling `<button>`.\n- Mobile layout: content must never run past the 1.25rem gutter. A flex or grid child that holds long inline `code` needs\n  `min-width: 0`, and the code needs `overflow-wrap: anywhere` (`break-word` does not lower the min-content width, so the\n  item still overflows). Bootstrap `.row` blocks in content lose their negative margins below 760px. To check, load pages in\n  375px iframes and look for elements whose right edge passes `#cas-docs-container` outside a scrolling ancestor.\n- Release notes pages carry `docs-release-notes` on `<body>` (set in `_layouts/default.html` from the page URL); their prose,\n  footer included, is justified and hyphenated, while stats, facts, the release switcher and inline code are left alone.\n- Docs sidebar: `sidebar.md` stays a plain nested list; `buildSidebarTree()` in `site.js` turns it into the grouped tree. A\n  section's group comes from `CAS_SIDEBAR_GROUPS` (by the section's `#anchor`), so add a new top-level section there too; one\n  left out lands in the last group. A parent whose first child is an `Overview` link becomes that link (the row is dropped);\n  other parents only toggle. Only the current path opens, siblings close as another opens, open rows are sticky at\n  `--sidebar-filter-h + depth × --sidebar-row-h`, and `.docs-sidebar` has no top padding so the filter can pin at the very top.\n- Settings in docs (required, standing rule from the maintainer): never refer to a configuration setting by name in\n  documentation pages, neither inline nor in fenced `properties` blocks. Describe the feature, say that it is available,\n  and say that it is enabled, disabled or tuned through CAS settings; the page's `casproperties` include lists them. The\n  only exception is release notes, which may name CAS or other settings.\n- Setting links in release notes (required): every setting named in release notes must be marked so readers can look\n  it up in place. Inline, write `` `cas.server.name`{: .cas-setting} ``\n  (an assignment such as `` `cas.tgc.secure=false`{: .cas-setting} `` works too; the value is ignored for the lookup). For a\n  fenced `properties` block of settings, put `{: .cas-settings-linked}` on the line right after the closing fence. Mark only\n  real CAS or Spring setting names, not prefixes used as headings, removed settings, JSON fields or registered-service\n  properties. `initializeCasSettingLinks()` in `site.js` turns these into buttons that call `openCasSettingsPalette(name)`:\n  an exact match opens the setting's details inside the Shift-Shift palette, a partial name shows the matches; the page\n  never changes. Linked blocks are rebuilt line by line from their text (key, separator, value), because Rouge's\n  properties lexer splits keys with `[0]` into separate tokens. `casSettingsFlat()` drops anything in brackets and any\n  purely numeric name segment, so `[0]`, `[]`, the catalog's map notation `.[key]`, `.0.` and the environment variable form\n  `_0_` all match the same setting. No catalog name has a numeric segment of its own; recheck that if one is ever added. `planning/Quick-Start.md` is the reference page.\n- Quick Start wizard (`planning/Quick-Start.md`, `javascripts/quick-start.js`): the page needs exactly eight `## N. ` steps;\n  anything else on the page is an `###` inside one of them, or it shows on every step. `data-quick-feature` takes a\n  space-separated list and shows the section when any listed feature is selected. Each checkbox's `data-module` must match\n  the `casmodule` include in its recipe (the fieldset's `data-modules` holds the always-included ones): step 2's\n  Initializr command and step 6's `dependencies` block are built from them, and step 6's `cas.properties` from every\n  visible `.cas-settings-linked` block in steps 3–5, in page order, under the nearest heading. Generated blocks are\n  plain fenced blocks inside `[data-quick-output]`; the script rewrites their text and gutter and calls\n  `linkCasSettingsBlock()` from `site.js` again. The selection is page state: popstate restores the step only, and a link\n  into a recipe selects its first feature with a notice. Checkboxes may declare `data-requires` and `data-excludes`\n  (space-separated feature values; Interrupt requires Groovy, Redis and Hazelcast exclude each other, declared on both\n  sides): checking selects requirements and clears conflicts, clearing a feature clears its dependents, Check All adds\n  only what fits, and a selection from the address is normalized with earlier features winning. Test the wizard in\n  headless Chromium against a rendered copy (Back/Forward, deep links, rules, Check All output) after changes.\n- Every documentation page needs an opening paragraph right after the H1 that the layout can use as its description:\n  at least 40 characters, not ending in a colon, and not a \"see this guide\" or dependency lead-in.\n- On phones (`max-width: 760px`) the sticky header is offset by `--docs-masthead-height` so only the navigation bar\n  stays visible, and `site.js` sets `--docs-header-height` to that bar alone; keep both in step when changing the masthead.\n- The docs load two web fonts only, Bricolage Grotesque (headings) and DM Sans (everything else); `--font-mono` is the system\n  monospace stack. Do not add font families or hotlink images from third-party hosts; keep images under `images/`.\n- Large topics are split into one page per concern with a sidebar submenu (see `authorization/Heimdall-Authorization-*.md`\n  and `multitenancy/Multitenancy-*.md`); when moving a section, update cross-page anchors and links in release notes.\n\n## FIDO2 WebAuthn / passkeys review discipline\n\n- JSON examples pasted into the documentation are pretty-printed (one member per line, two-space indent), never minified.\n",".claude/skills/cas-import-pr/SKILL.md":"---\nname: cas-import-pr\ndescription: \"Bring an apereo/cas GitHub pull request onto a local branch without a git identity, keeping the contributor as author, by fetching each non-merge commit as a patch and applying it with git am. Use when asked to review, fix up or test a contributor PR locally.\"\n---\n\n# Importing a contributor pull request\n\n1. List the PR's commits (`https://api.github.com/repos/apereo/cas/pulls/<n>/commits`) and drop the\n   \"Merge branch 'master'\" commits.\n2. Cut a local branch from master.\n3. For each remaining commit, oldest first, fetch the patch and apply it as described below.\n4. Leave the result uncommitted beyond the contributor's own commits; the maintainer commits review fixes himself\n   (`cas-handoff` skill).\n\n## Environment limits\n\n- The remote shell has no git identity. To bring a contributor's PR onto a local branch, fetch each non-merge commit as a patch from `https://api.github.com/repos/apereo/cas/commits/<sha>` with `Accept: application/vnd.github.patch` and apply it with `git -c user.name=... -c user.email=... am --3way`, which keeps the contributor as author. Skip the \"Merge branch 'master'\" commits; the local branch is cut from master already.\n",".claude/skills/cas-interrupt-notifications/SKILL.md":"---\nname: cas-interrupt-notifications\ndescription: \"Review and change interrupt notifications: InterruptWebflowConfigurer, inquirers, the tracking cookie, passive requests, SSO reuse by protocol modules. Use when touching support/cas-server-support-interrupt*.\"\n---\n\n# Interrupt notifications\n\n## Interrupt notifications\n\n- Review the whole webflow path in `InterruptWebflowConfigurer`, not a single action: inquiry is wired at several\n  states, and the request-scoped finalized marker decides whether later checkpoints re-run the inquirers.\n- Treat the tracking cookie as a user-held acknowledgement. Changes to finalize/tracking must keep blocked\n  responses unacknowledgeable and must consider which principal the cookie was issued for.\n- Puppeteer interrupt scenarios often log out between logins, which removes the tracking cookie; a regression step for tracking must log in again without logging out.\n- Passive requests (CAS `gateway`, OIDC `prompt=none`, SAML `IsPassive`) must not render the interrupt view. When adding an event to an action that is prepended to an existing action state, add the matching transition to that state; an unmatched event silently continues to the next action.\n- A check that only runs in the login webflow is bypassed wherever a protocol module reuses the SSO session directly (for example the SAML2 IdP). For interrupts this is accepted for now; do not close it by vetoing SSO participation from the interrupt module without agreeing on the approach with the maintainer.\n- Inquirer errors currently fail open; any fix must state the intended behavior for blocking policies.\n- Reloadable inquirer state (files, watchers) must be published atomically; a failed reload keeps the last good contents rather than failing open.\n- Payloads map onto `InterruptResponse`, whose no-arg constructor means \"interrupt\". Parse external responses only after the status check, and test error bodies against random-port `MockWebServer` instances.\n",".claude/skills/cas-ldap/SKILL.md":"---\nname: cas-ldap\ndescription: \"Review and change LDAP integration: LdapUtils, connection factories and pools, search result handling, password transport, fail-open authorization, logging of directory data. Use when touching LDAP authentication, attribute repositories, password management or any LDAP-backed module.\"\n---\n\n# LDAP\n\n## LDAP review discipline\n\n- Start at the shared seam. `LdapUtils` builds connection configs, filters, authenticators, DN/entry resolvers\n  and person-attribute DAOs for every LDAP-backed feature; `LdapConnectionFactory` wraps every search, modify,\n  add, delete and password-modify. Findings there generalize; findings in one feature module usually do not.\n- Follow the whole flow: property model -> `LdapUtils` construction -> auto-configuration bean -> the calling\n  repository or handler -> the webflow/endpoint boundary that consumes the result. Most real defects live in\n  the mismatch between two of those layers, not inside one class.\n- Search results are a trust boundary. Check the LDAP result code, not just whether an entry came back, and\n  check what happens on truncation (server size/time/admin limits), on referral or continuation references,\n  and on multi-valued attributes that directories return in ranges.\n- Connection factories are pooled and stateful. Confirm they are created once as beans, that a pool used for\n  user binds is passivated back to its original bind state, and that maps of factories are keyed by something\n  that actually distinguishes two configurations.\n- For any code that reads or writes a password, verify the transport requirement against the current RFC text\n  and confirm the guard is enforcement rather than a log line; read the boolean carefully for inverted sense.\n- Treat a fail-open authorization branch as critical regardless of how it is reached. Trace the default value\n  of every property the branch depends on, including Spring Boot defaults CAS does not override.\n- Watch for values that are written by one path and compared by another, and for filter/template parameters\n  that are silently ignored when the operator's template does not reference them.\n- Secrets and directory internals must not reach logs or released attributes: password-reset answers, bind\n  credentials, diagnostic messages and matched DNs all deserve a second look.\n",".claude/skills/cas-heimdall-authzen/SKILL.md":"---\nname: cas-heimdall-authzen\ndescription: \"Review and change Heimdall authorization and its AuthZEN PDP endpoints (support/cas-server-support-heimdall*): request parsing, caller authentication, policies (JDBC, JSON resources), evaluations, PDP metadata, throttling and the heimdall-authzen/heimdall-nginx scenarios. Use when touching Heimdall code, docs or scenarios.\"\n---\n\n# Heimdall and AuthZEN\n\n## Heimdall / AuthZEN review discipline\n\n- Check the latest OpenID Authorization API final specification and editor's draft. AuthZEN is a PEP/PDP\n  protocol; wallet exchange uses OID4VCI/OID4VP. Treat a verified-presentation-to-policy bridge as a feature.\n- Trace `DefaultAuthorizationPrincipalParser` through `DefaultAuthorizationEngine` and each policy.\n  A valid caller token does not establish PDP audience, PEP permissions or DPoP proof possession;\n  delegated subject ids are legitimate for trusted PEPs and are not inherently impersonation.\n- An AuthZEN `resource.id` names the resource instance, never a policy namespace. AuthZEN requests\n  (`AuthorizationRequest.isAuthZen()`: subject, resource and action all present) match `resourceType`, `actions` and an\n  optional full-match `resourceIdPattern` across every namespace, and every match must grant. Normal requests\n  keep namespace + URI pattern + method and `/heimdall/authorize` rejects AuthZEN fields, which keeps the paths apart.\n- Callers authenticate with tokens issued to a registered OAuth/OIDC service whose access strategy allows\n  access; `HeimdallRegisteredServiceAccessStrategy` (alone or chained) can refuse it. DPoP and `x509_digest`\n  certificate bindings are enforced. `x509_digest` is the RFC 8705 `x5t#S256` thumbprint (base64url SHA-256 of the\n  DER certificate); compute it only with `OAuth20Utils.computeCertificateThumbprint` so issuer and verifiers agree. On AuthZEN, Basic means `client_id:client_secret` (reject clients without secrets:\n  `DefaultOAuth20ClientSecretValidator.validate` returns true when none is defined); on `/heimdall/authorize` it\n  stays CAS user credentials, split on the first colon. Both endpoints are throttled; `HeimdallThrottledHandlerInterceptor`\n  forwards only 401s to the throttle interceptors' post-processing. A `ThrottledRequestFilter` cannot limit what\n  counts: the default `httpPost()` filter claims every POST and the plan combines filters with `anyMatch`, and the\n  interceptors record every non-2xx response (twice: `postHandle` and `afterCompletion`; harmless for decisions,\n  since stores keep the latest failure per key or read the authentication audit trail).\n  Create registry-backed single-use markers through the `TicketFactory` (TST factory, custom `ExpirationPolicy` in\n  a *mutable* properties map: `buildExpirationPolicy` removes that key, so `Map.of` throws), never by instantiating\n  ticket implementations.\n- macOS JDKs use a polling `WatchService` (2s): a file created and deleted between two polls produces no event.\n  Watcher-driven caches must reconcile against the file system on every event rather than trust event kinds. JWT assertions need `jti`/`iat`, are single-use,\n  and share the token endpoint's audiences on purpose. A resource without policies denies.\n- JDBC trusted parameters (`principal`, `method`, `uri`, `namespace` and the AuthZEN names) are added after\n  context and attributes, so they already win; an overwrite claim there was a false finding. Check that policy\n  file deletion removes cached grants. `enforceAllPolicies=false` means any one policy grants: a failing policy is\n  logged and skipped, and failures are rethrown only when nothing grants (never turn them into a silent deny).\n  `enforceAllPolicies=true` stops at the first denial or failure. An empty policy list denies before either runs.\n- JDBC policies resolve their `DataSource` by bean name (`dataSourceName`, else `heimdallJdbcDataSource-<sha256(url|username)>`)\n  and register a `JpaBeans.newPoolingDataSource` pool (Hikari defaults, `minimumIdle=0`) through\n  `GenericApplicationContext.registerBean` under a lock, so Spring closes it on shutdown. Never cache a pool on a\n  policy instance (policies are rebuilt on every file reload) and never use `registerSingleton` for closeable beans:\n  Spring gives registered singletons no destruction callbacks. The pool key ignores the password (a rotated password\n  needs a new `dataSourceName` or a restart; documented). `queryTimeout` (default `PT5S`) is set on the `JdbcTemplate`. Tests override `resolveApplicationContext()` with a\n  local context rather than relying on the static `ApplicationContextProvider`, which parallel tests share.\n- `JsonAuthorizableResourceRepository` publishes one immutable `ResourceIndex` (by namespace and by AuthZEN\n  `resourceType`) per reload; AuthZEN lookups use the type index, so keep both maps in the same snapshot.\n- Policies evaluate sequentially; do not reintroduce `parallelStream()` (blocking policies starve `commonPool`).\n- AuthZEN subjects are resolved through the principal resolver only for subject type `user` (hardcoded);\n  other types become a bare principal. Policies read request data through\n  `AuthorizationRequest.resolveAttributeValues` (qualified `subject.*`, `resource.*`, `action.*`, `context.*` names);\n  never merge caller-supplied properties into principal attributes, which would let a PEP override the directory.\n  AuthZEN `context` is body-only; `/heimdall/authorize` adds non-protocol headers with `putIfAbsent`.\n- Palantir rebuilds resources from a fixed field list (`heimdallResourceForStorage`) and re-saves the whole\n  namespace; any new `AuthorizableResource` field must be added there or an edit silently drops it.\n- AuthZEN PDP metadata: the identifier is `<prefix>/heimdall`, served at `/cas/heimdall/.well-known/authzen-configuration`\n  (`HeimdallAuthZenConfigurationController`). The spec inserts the well-known segment after the host\n  (`/.well-known/authzen-configuration/cas/heimdall`), outside the CAS context: deployments need a proxy or ENGINE\n  rewrite-valve rule, as in the `heimdall-authzen` scenario's `rewrite.config`. `policy_decision_point` must equal the\n  identifier the PEP started from. List only endpoints that exist (no search yet).\n- AuthZEN decision context: denials carry `context.reason` from `AuthorizationDecisionReason` (set by the engine:\n  `subject_unresolved`, `no_matching_resource`, `no_policies`, `policy_denied`); grants carry no context. Never put\n  policy names, attribute names or messages in it; details go to DEBUG logs. Search (R5) and the OID4VP wallet bridge\n  (R7) are deferred and noted as future work in the Heimdall and verifiable credentials docs.\n- AuthZEN evaluations (`/heimdall/authzen/evaluations`): top-level subject/resource/action/context are defaults that an\n  entry replaces per key. Authenticate the caller once (`authenticateCaller`), then `resolveSubject` per entry;\n  never call `parse` per entry (it would consume single-use JWT assertions). Per-entry failures are `decision:false` with\n  `context.error.{status,message}` (no exception text for 500s); short-circuit semantics omit the remaining entries;\n  no/empty `evaluations` falls back to the single evaluation. Evaluations run sequentially.\n- AuthZEN evaluated denials use HTTP 200 with `decision:false`; authentication failures use 401.\n  Discovery/batch/search are separate capabilities, and the specification's example endpoint path is not mandatory.\n- Read shared helpers before reporting leaks: request headers already filter credentials and the request\n  principal is JSON-ignored. Confirm performance severity with evidence; blocking policy parallel streams\n  and unpooled JDBC merit investigation, not an unmeasured claim of outage.\n- Puppeteer scenario `heimdall-authzen` covers the AuthZEN endpoint end to end (decisions across namespaces,\n  id patterns, deny-wins, empty policies, `X-Request-ID`, unknown fields, 400/401, the Heimdall access strategy,\n  client-credential and bearer PEPs, the legacy endpoint, the actuator and throttling). It also enables throttling,\n  so keep 401-producing steps at least one throttle window apart. Palantir scenarios only check that tabs load.\n- Scenario `heimdall-nginx` drives the shared nginx config (`ci/tests/nginx`) as a PEP: `/api` uses `auth_request`\n  against `/heimdall/authorize`. The subrequest must set `proxy_method POST` (it is a GET otherwise) and send a\n  `namespace`; nginx does not escape variables in `proxy_set_body`, so URIs with `\"` or `\\` are rejected by a `map`.\n  `auth_request` maps any status other than 2xx/401/403 (such as Heimdall's 404) to 500. Validate edits with a local\n  `nginx -t` and stub upstreams; the config resolves `host.docker.internal` at load time.\n",".claude/skills/cas-mcp-server/SKILL.md":"---\nname: cas-mcp-server\ndescription: \"Review and extend the CAS MCP server (support/cas-server-support-mcp): @McpTool beans, security and auditing, optional tool groups, typed results and output schemas, secret redaction, resources and completion, tests and the mcp-server-* scenarios. Use when adding or changing MCP tools, resources or prompts.\"\n---\n\n# CAS MCP server module\n\n## MCP server module (`support/cas-server-support-mcp`)\n\n- Tools are plain beans whose methods carry `@McpTool` plus `@PreAuthorize(\"hasRole('MCP_ADMIN')\")`;\n  `CasMcpToolsTests.verifyToolsAreSecuredByProxies` asserts every such bean is an AOP proxy. Read tools need `MCP_ADMIN`.\n- Tools that change CAS (`AI.mcp-write`, off by default) live in separate `*AdministrationTools` beans so the toggle can drop\n  them: core ones in `CasAdministrationTools` (sessions with SLO through `SingleLogoutRequestExecutor`, throttle release via\n  `ThrottledSubmissionsStore.removeIf`, `setServiceEnabled` on `DefaultRegisteredServiceAccessStrategy` including chained\n  ones, `setLogLevel` through Spring Boot's `LoggingSystem`), optional ones as extra `@Bean` methods in the group's nested\n  configuration, or a nested configuration of their own (`CasMcpOAuthAdministrationConfiguration`). Each bean method carries\n  `@ConditionalOnFeatureEnabled(module = \"mcp-write\", enabledByDefault = false)`, each tool `hasRole('MCP_OPERATOR')`,\n  `readOnlyHint = false` and `destructiveHint = true` unless harmless. They accept session references and record ids from\n  the read tools, never raw TGT or token ids. Tests: `CasMcpToolsTests` turns `mcp-write` on (bean level, `MCP_OPERATOR`\n  mock user, Mockito `verify` on the mocked MFA repositories), `CasMcpServerTests.WriteToolsTests` (MockMvc, `mcpoperator`,\n  tokens), and `ProtocolTests` asserts they are not listed by default. Checkstyle rejects `public LoggerXxx` and a parameter\n  named `logger` (non-static logger rule).\n- A tool over an optional feature follows the consent pattern: `compileOnly` (and `testImplementation`) on that module, a\n  nested `@Configuration` with `@ConditionalOnClass` and feature module `mcp-<group>`, an `ObjectProvider` for the backing\n  bean and a `reason` in the result when it is absent; test activation in `CasMcpAutoConfigurationTests`.\n- Never return secrets: device fingerprints, trusted-device record keys, client secrets, key material, setting values.\n  Tests assert the secret string is absent from the result.\n- A turned-off events repository is a `BeanSupplier` proxy, not null: check `BeanSupplier.isProxy`.\n- Every `@McpTool`/`@McpResource` call is audited by one infrastructure advisor (`CasMcpAuditInterceptor`, ordered before\n  method security so denials are recorded) that hands a `CasMcpOperation` (an `AuditableEntity`, principal = the MCP\n  user) to the `@Audit`-annotated `CasMcpAuditor`. New tools are covered automatically; never add `@Audit` to tool\n  methods (the default audit principal resolver would take the first argument, the person being looked up).\n- Tools summarize; they never return serialized CAS objects (tickets, services' secrets) or attribute values outside\n  findPerson/previewAttributeRelease. Ticket-granting ticket ids are bearer tokens: return\n  `CasMcpSessionReferences.of(id)` and accept references back through `CasMcpSessionReferences.resolve` (with the\n  username). Service JSON from findService/matchService goes through the secret redaction in `CasServiceManagementTools`.\n- MCP capabilities are set in `webapp/cas-server-webapp-resources/src/main/resources/application.properties`\n  (`spring.ai.mcp.server.capabilities.*`) and mirrored in `BaseCasMcpTests`; a `@McpResource` or `@McpPrompt` bean is\n  silently not advertised while its capability is false. Resources (`org.apereo.cas.mcp.resources`) return JSON built\n  with a plain `JsonMapper`: CAS's `JacksonObjectMapperFactory` mappers use `NON_DEFAULT` inclusion and drop `false`/`0`.\n- `DefaultAuthenticationRiskEvaluator` is `@Transactional` on `transactionManagerEvents`; test contexts that use a real\n  evaluator need that bean (`PseudoTransactionManager`, as `CasCoreEventsAutoConfiguration` registers it).\n- Tools that evaluate a person at an application build their subject with `CasMcpEvaluationContextFactory.build`\n  (service, matching registered service, resolved principal, `authentication()`); request-aware components get\n  `currentHttpExchange()`, the live request with parameters, headers, cookies and query string hidden, or\n  `newWebflowRequestContext(context)`, a webflow request context over it with service, registered service, credential\n  and authentication in scope. Do not resolve services or principals by hand in a tool. Tools that need client details\n  (IP address, user agent, location) take them as parameters and wrap them in `CasMcpClient`: `ClientInfoHolder`\n  describes the MCP operator. `currentHttpExchange(client)` exposes them as remote address, `User-Agent` and the\n  `geolocation` parameter; `withClientInfo(client, ...)` swaps `ClientInfoHolder` only around components that read it\n  (MFA triggers). Do not swap it around audited calls such as the risk evaluator, or the audit trail records the\n  supplied address instead of the operator's.\n- A new tool also goes into `CAS_TOOLS` (`CasMcpServerTests`, `mcp-server-tools/script.js` when core), the role-denial\n  lists, `FeatureDisabledTests`, and the tables and feature-toggle list in `Admin-MCP-Server.md`. A new optional group also\n  goes into `CasDeploymentTools.MCP_TOOL_GROUPS` (feature module to bean name), which `describeDeployment` reports.\n- Tools return typed records with `@McpTool(generateOutputSchema = true)` (only findService/matchService return free-form\n  JSON strings): tools/list then carries an `outputSchema` and calls return `structuredContent`, which the MCP SDK validates\n  against the schema and copies into the text content. Mark optional components `@Nullable` (jspecify) and annotate the\n  record `@JsonInclude(NON_NULL)`: non-null components are `required` in the schema and a `null` fails validation. Never\n  return `null` from a typed tool; return the record with a `reason`. Keep JSON names stable (puppeteer reads them), use\n  `String` for dates, `Object` for nested CAS objects whose shape should not become schema, `@JsonProperty` for Java\n  keywords such as `default`, and Lombok `@Builder` for records with many optional components. Bean-level tests use the\n  record accessors.\n- A tool that combines others (explainLogin) calls the other tool beans and converts their records to maps with a plain\n  `JsonMapper`. `CasMcpAuditInterceptor` binds a `ScopedValue` for the outermost call, so nested tool calls are not audited.\n- Puppeteer builds CAS with `-DskipNestedConfigMetadataGen=true`, so the settings catalog at runtime holds only third-party\n  (Spring) settings and no `cas.*` ones. Scenarios exercise settings tools with a Spring setting such as\n  `spring.ai.mcp.server.name`; `cas.*` settings and the `module` filter are covered by unit tests (`McpSettingsMetadata.json`).\n- MCP completion (`@McpComplete`) only completes resource-template and prompt arguments, never tool parameters. Completion\n  methods carry `@PreAuthorize` like tools and are not audited; `spring.ai.mcp.server.capabilities.completion` must be true.\n- Claude's shells have no JDK 25 or Maven Central; `build/mcp-run.sh` compiles, runs checkstyle, the `RestfulApi` tests\n  and the MCP puppeteer scenarios on the Mac.\n- OAuth resource checks use `OAuth20RequestParameterResolver.resolveAllowedResources(service)`, which adds the CAS-wide\n  `cas.authn.oauth.core.allowed-resources` (default: the CAS MCP endpoint) to the service's `allowedResources`; never\n  match against `getAllowedResources()` alone. `AbstractOAuth20Tests` and `BaseCasMcpTests` allow `https://cas.example.org/cas/mcp`.\n- Bearer access tokens (`AI.mcp-oauth`, nested `CasMcpOAuthConfiguration`, needs OIDC for the issuer): the filter\n  `CasMcpBearerTokenAuthenticationFilter` is built inside a `CasWebSecurityConfigurer`, never exposed as a bean (Spring Boot\n  registers `Filter` beans for every request). It sits before `BasicAuthenticationFilter`, acts only on the MCP path with a\n  `Bearer` header, sets a `CasMcpBearerTokenAuthentication` (principal id, client id, scopes) without saving it, and grants\n  `ROLE_MCP_ADMIN` only from `cas.mcp.oauth.required-attributes` (empty matches nobody), and `ROLE_MCP_OPERATOR` only with the\n  `mcp:write` scope plus `cas.mcp.oauth.operator-required-attributes` (`grantRoles`), checked against attributes resolved\n  through `defaultPrincipalResolver` (token principal as current principal): a token's principal carries only the attributes\n  released to the client, so `memberOf` and the like are usually missing there. The unauthenticated challenge comes from\n  `defaultAuthenticationEntryPointFor(..., mcp matcher)`, registered before `httpBasic` initializes so it wins. Being first\n  also makes it Spring Security's fallback for requests no entry point claims (browser requests to actuators), so outside\n  the MCP path it answers with the Basic challenge alone. A configurer must never set an explicit `authenticationEntryPoint`,\n  which replaces every other one; Palantir's CAS login registers its entry point with `defaultAuthenticationEntryPointFor`\n  for `/palantir/**` only (`DashboardControllerTests.CasAuthenticationTests`). The security context is saved in a request attribute only (async SSE dispatches need\n  it), never in the HTTP session.\n- Spring Security's Basic challenge is `Basic realm=\"Realm\", charset=\"UTF-8\"` (MockMvc reports it as more than one value), so\n  `CasMcpAuthenticationEntryPoint` delegates to `BasicAuthenticationEntryPoint` rather than writing it, and tests compare with\n  that entry point's output (`CasMcpServerTests.basicChallenge`). `jsonPath(...).value(List.of(...))` reads `null` because the\n  expected value's class (`List.of`'s immutable type) becomes the target type; assert `[0]` and `length()` instead. Protected resource metadata lives under the CAS\n  context (`/cas/.well-known/oauth-protected-resource/mcp`), because the root well-known path is outside the web app; clients\n  reach it through `resource_metadata`. Tests build `OAuth20DefaultAccessToken` directly and add it to the registry\n  (`CasMcpServerTests.newAccessToken`); `mcp-server-saml-oidc` gets a JWT token through the password grant.\n- `CasMcpEvaluationContextFactory` hands the username to the principal resolvers as the current principal. Without attribute repositories the chain holds only `EchoingPrincipalResolver`, which echoes the current principal, so resolving without one yields `NullPrincipal` (`nobody`).\n- OAuth/OIDC services serialize their secret as `clientSecrets` (a list of objects with `value`); `getClientSecret()` is `@JsonIgnore`. Redaction of service definitions must cover the plural property, which `SECRET_PROPERTY` in `CasServiceManagementTools` does with `secrets?`.\n- `CasOidcTools.checkOidcClientResource` reuses `RegisteredServiceOAuthResourceMatcher` (`isValidResource`,\n  `findAllowedResource`) so the tool and the protocol endpoints decide resource indicators the same way; new tools are\n  added to `CasMcpServerTests.CAS_TOOLS` and to the optional-tool lists of the `mcp-server-*` scenarios.\n",".claude/skills/cas-oauth-oidc-authentication/SKILL.md":"# cas-oauth-oidc-authentication\n\nPath: `.claude/skills/cas-oauth-oidc-authentication/SKILL.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/apereo/cas/HEAD/.claude/skills/cas-oauth-oidc-authentication/SKILL.md)",".claude/skills/cas-oauth-oidc/SKILL.md":"# cas-oauth-oidc\n\nPath: `.claude/skills/cas-oauth-oidc/SKILL.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/apereo/cas/HEAD/.claude/skills/cas-oauth-oidc/SKILL.md)",".claude/skills/cas-oid4vc/SKILL.md":"# cas-oid4vc\n\nPath: `.claude/skills/cas-oid4vc/SKILL.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/apereo/cas/HEAD/.claude/skills/cas-oid4vc/SKILL.md)",".claude/skills/cas-passwordless-webauthn/SKILL.md":"# cas-passwordless-webauthn\n\nPath: `.claude/skills/cas-passwordless-webauthn/SKILL.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/apereo/cas/HEAD/.claude/skills/cas-passwordless-webauthn/SKILL.md)",".claude/skills/cas-protocol/SKILL.md":"# cas-protocol\n\nPath: `.claude/skills/cas-protocol/SKILL.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/apereo/cas/HEAD/.claude/skills/cas-protocol/SKILL.md)",".claude/skills/cas-puppeteer-scenarios/SKILL.md":"# cas-puppeteer-scenarios\n\nPath: `.claude/skills/cas-puppeteer-scenarios/SKILL.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/apereo/cas/HEAD/.claude/skills/cas-puppeteer-scenarios/SKILL.md)",".claude/skills/cas-saml/SKILL.md":"# cas-saml\n\nPath: `.claude/skills/cas-saml/SKILL.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/apereo/cas/HEAD/.claude/skills/cas-saml/SKILL.md)",".claude/skills/cas-sandbox-verification/SKILL.md":"# cas-sandbox-verification\n\nPath: `.claude/skills/cas-sandbox-verification/SKILL.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/apereo/cas/HEAD/.claude/skills/cas-sandbox-verification/SKILL.md)",".claude/skills/cas-service-registries/SKILL.md":"# cas-service-registries\n\nPath: `.claude/skills/cas-service-registries/SKILL.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/apereo/cas/HEAD/.claude/skills/cas-service-registries/SKILL.md)",".claude/skills/cas-session-stores/SKILL.md":"# cas-session-stores\n\nPath: `.claude/skills/cas-session-stores/SKILL.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/apereo/cas/HEAD/.claude/skills/cas-session-stores/SKILL.md)",".claude/skills/cas-startup-performance/SKILL.md":"# cas-startup-performance\n\nPath: `.claude/skills/cas-startup-performance/SKILL.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/apereo/cas/HEAD/.claude/skills/cas-startup-performance/SKILL.md)",".claude/skills/cas-storage-backends/SKILL.md":"# cas-storage-backends\n\nPath: `.claude/skills/cas-storage-backends/SKILL.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/apereo/cas/HEAD/.claude/skills/cas-storage-backends/SKILL.md)",".claude/skills/cas-testing/SKILL.md":"# cas-testing\n\nPath: `.claude/skills/cas-testing/SKILL.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/apereo/cas/HEAD/.claude/skills/cas-testing/SKILL.md)",".claude/skills/cas-ticket-registries/SKILL.md":"# cas-ticket-registries\n\nPath: `.claude/skills/cas-ticket-registries/SKILL.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/apereo/cas/HEAD/.claude/skills/cas-ticket-registries/SKILL.md)",".claude/skills/cas-ui/SKILL.md":"# cas-ui\n\nPath: `.claude/skills/cas-ui/SKILL.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/apereo/cas/HEAD/.claude/skills/cas-ui/SKILL.md)",".claude/skills/cas-web-security/SKILL.md":"# cas-web-security\n\nPath: `.claude/skills/cas-web-security/SKILL.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/apereo/cas/HEAD/.claude/skills/cas-web-security/SKILL.md)","AGENTS.md":"# AI Agent Protocol & Instructions\n\nPath: `AGENTS.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/apereo/cas/HEAD/AGENTS.md)","CLAUDE.md":"# Claude Agent Guidelines & System Prompt\n\nPath: `CLAUDE.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/apereo/cas/HEAD/CLAUDE.md)",".github/copilot-instructions.md":"# GitHub Copilot Instructions\n\nPath: `.github/copilot-instructions.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/apereo/cas/HEAD/.github/copilot-instructions.md)",".cursorrules (testrail-test-case-cursorrules-prompt-file.mdc)":"---\ndescription: \"Cursor rules for TestRail development with test case integration.\"\nglobs: **/*\nalwaysApply: false\n---\n# Persona\n\nYou are an experienced QA Engineer with expertise in writing clear, detailed test cases\nfor TestRail that help testers efficiently execute tests and validate functionality.\nYou understand testing methodologies and how to structure test cases that provide\ncomprehensive coverage while remaining maintainable and reusable.\n\n# Documentation Focus\n\nFocus on creating standardized TestRail test cases with these key components:\n\n- Clear, descriptive test case title\n- Concise test case objective/purpose\n- Preconditions required for test execution\n- Step-by-step test procedure with expected results\n- Test data requirements\n- Post-conditions after test execution\n- Appropriate custom fields for TestRail\n\n# Best Practices\n\n**1** **Clear Title**: Create descriptive, specific test case titles\n**2** **Complete Preconditions**: List all necessary setup requirements\n**3** **Numbered Steps**: Present test steps in a clear, sequential order\n**4** **Explicit Expected Results**: Specify precise expected outcomes for each step\n**5** **Appropriate Test Type**: Assign the correct test type (functional, regression, etc.)\n**6** **Test Data**: Include specific test data values where applicable\n**7** **Environment Details**: Specify relevant environment information\n**8** **Organized Sections**: Group related test cases in logical sections\n\n# TestRail CSV Format Example\n\n```csv\nTitle,Section,Type,Priority,Preconditions,Steps,Expected Results\n\"Login with Valid Credentials\",\"User Authentication\",\"Functional\",\"Critical\",\"User has valid account credentials\",\"1. Navigate to the login page.\n2. Enter valid username 'testuser'.\n3. Enter valid password 'Password123'.\n4. Click the 'Login' button.\",\"1. Login page loads correctly.\n2. Username field accepts input.\n3. Password field accepts input.\n4. User is successfully logged in and redirected to the dashboard.\"\n\"Login with Invalid Password\",\"User Authentication\",\"Functional\",\"High\",\"User has valid account credentials\",\"1. Navigate to the login page.\n2. Enter valid username 'testuser'.\n3. Enter invalid password 'wrongpassword'.\n4. Click the 'Login' button.\",\"1. Login page loads correctly.\n2. Username field accepts input.\n3. Password field accepts input.\n4. Error message is displayed: 'Invalid username or password'.\"\n\"Password Reset Request\",\"User Authentication\",\"Functional\",\"Medium\",\"User has registered email address\",\"1. Navigate to the login page.\n2. Click the 'Forgot Password' link.\n3. Enter valid email 'test@example.com'.\n4. Click the 'Reset Password' button.\",\"1. Login page loads correctly.\n2. Forgot password page loads correctly.\n3. Email field accepts input.\n4. Success message is displayed: 'Password reset link sent'.\"\n```\n\n# Converting Automated Tests to TestRail Format\n\nWhen converting automated tests or feature descriptions to TestRail format:\n\n1. Identify the overall test objective and create a descriptive title\n2. Extract preconditions from setup code or implied requirements\n3. Convert test actions into numbered steps\n4. Transform assertions into expected results\n5. Group related test cases into sections\n6. Assign appropriate test types and priorities\n7. Include specific test data values\n8. Add environmental notes if necessary\n\nExample:\n\nAutomated Test:\n\n```js\ndescribe('Login Functionality', () => {\n  it('should allow login with valid credentials', () => {\n    cy.visit('/login');\n    cy.get('#username').type('testuser');\n    cy.get('#password').type('Password123');\n    cy.get('#loginButton').click();\n    cy.url().should('include', '/dashboard');\n    cy.get('.welcome-message').should('contain', 'Welcome, testuser');\n  });\n});\n```\n\nTestRail CSV Format:\n\n```csv\nTitle,Section,Type,Priority,Preconditions,Steps,Expected Results\n\"Login with Valid Credentials\",\"User Authentication\",\"Functional\",\"Critical\",\"User has valid account credentials\",\"1. Navigate to the login page.\n2. Enter valid username 'testuser'.\n3. Enter valid password 'Password123'.\n4. Click the 'Login' button.\",\"1. User is redirected to the dashboard page.\n2. Welcome message is displayed: 'Welcome, testuser'.\"\n```\n\n# Test Case Structure\n\nStructure TestRail test cases using this format:\n\n```\n# Test Case: [Descriptive title]\n\n## Section\n[Section/Module/Feature]\n\n## Priority\n[Critical/High/Medium/Low]\n\n## Type\n[Functional/Regression/Usability/Performance/Security/etc.]\n\n## Objective\n[Clear statement of what the test aims to verify]\n\n## Preconditions\n1. [Precondition 1]\n2. [Precondition 2]\n...\n\n## Test Data\n- [Test data item 1: value]\n- [Test data item 2: value]\n...\n\n## Steps and Expected Results\n| # | Step | Expected Result |\n|---|------|----------------|\n| 1 | [Action to perform] | [Expected outcome] |\n| 2 | [Action to perform] | [Expected outcome] |\n...\n\n## Post-conditions\n1. [Post-condition 1]\n2. [Post-condition 2]\n...\n\n## Automation Status\n[Not Automated/To Be Automated/Automated]\n\n## References\n- [Requirement ID/User Story/Documentation Link]\n```\n\n# Example Test Case\n\nHere's an example of a well-structured TestRail test case:\n\n```\n# Test Case: User Login with Valid Credentials\n\n## Section\nAuthentication\n\n## Priority\nHigh\n\n## Type\nFunctional\n\n## Objective\nVerify that a user can successfully log in to the application using valid credentials.\n\n## Preconditions\n1. The application is accessible\n2. The test user account exists in the system\n3. The user is not currently logged in\n\n## Test Data\n- Username: test_user@example.com\n- Password: Test@123\n- User Role: Standard User\n\n## Steps and Expected Results\n| # | Step | Expected Result |\n|---|------|----------------|\n| 1 | Navigate to the login page | The login page is displayed with username and password fields, and a login button |\n| 2 | Enter valid username \"test_user@example.com\" in the username field | Username is accepted and displayed in the field |\n| 3 | Enter valid password \"Test@123\" in the password field | Password is accepted and masked in the field |\n| 4 | Click the \"Login\" button | The system authenticates the user and redirects to the dashboard |\n| 5 | Verify user information displayed in the header/profile section | Username \"test_user@example.com\" is displayed correctly |\n\n## Post-conditions\n1. User is logged in to the application\n2. User session is created\n3. User can access functionality based on their permissions\n\n## Automation Status\nAutomated\n\n## References\n- Requirement: REQ-AUTH-001\n- User Story: US-102\n```\n\n# Negative Test Case Example\n\nHere's an example of a negative test case:\n\n```\n# Test Case: User Login with Invalid Password\n\n## Section\nAuthentication\n\n## Priority\nHigh\n\n## Type\nFunctional\n\n## Objective\nVerify that the system correctly handles login attempts with an invalid password.\n\n## Preconditions\n1. The application is accessible\n2. The test user account exists in the system\n3. The user is not currently logged in\n\n## Test Data\n- Username: test_user@example.com\n- Password: WrongPassword123\n- User Role: Standard User\n\n## Steps and Expected Results\n| # | Step | Expected Result |\n|---|------|----------------|\n| 1 | Navigate to the login page | The login page is displayed with username and password fields, and a login button |\n| 2 | Enter valid username \"test_user@example.com\" in the username field | Username is accepted and displayed in the field |\n| 3 | Enter invalid password \"WrongPassword123\" in the password field | Password is accepted and masked in the field |\n| 4 | Click the \"Login\" button | The system displays an error message \"Invalid credentials. Please try again.\" |\n| 5 | Verify the user remains on the login page | The login page is still displayed with empty password field |\n| 6 | Verify the username field retains the entered username | Username \"test_user@example.com\" is still displayed in the field |\n\n## Post-conditions\n1. User remains logged out\n2. No user session is created\n3. Failed login attempt is logged in the system\n\n## Automation Status\nAutomated\n\n## References\n- Requirement: REQ-AUTH-002\n- User Story: US-103\n```\n\n# TestRail Specifics\n\nKeep these TestRail-specific considerations in mind:\n\n1. TestRail supports custom fields that may be specific to your organization\n2. TestRail allows for organization of test cases into sections and sub-sections\n3. Test cases can be added to test plans and assigned to testers\n4. TestRail allows for recording of test results and defects\n5. Automation status is often a key field for tracking automation coverage\n6. References to requirements, user stories, or other artifacts help with traceability\n\n# Test Case Writing Best Practices\n\nWhen writing TestRail test cases, follow these best practices:\n\n1. Use clear, descriptive titles that summarize what is being tested\n2. Write steps that are atomic, specific, and contain a single action\n3. Specify expected results for each step, not just the final outcome\n4. Include all necessary preconditions to ensure test reproducibility\n5. Specify concrete test data rather than vague descriptions\n6. Make test cases independent and self-contained when possible\n7. Use consistent language and terminology across all test cases\n8. Create reusable test cases that can be part of multiple test plans\n9. Include both positive and negative test scenarios\n10. Consider boundary values, equivalence partitions, and edge cases\n\n# Test Case Adaptation\n\nAdapt your test cases based on:\n\n- The specific product or feature being tested\n- Project-specific TestRail custom fields\n- Team-specific test case organization and naming conventions\n- Integration requirements with other tools (JIRA, DevOps, etc.)\n- Automation needs and frameworks\n\nWhen creating test cases, focus on providing clear guidance to testers\nwhile ensuring comprehensive coverage of functionality and edge cases.\n"},"files":{".claude/skills/cas-groovy-scripting/SKILL.md":"---\nname: cas-groovy-scripting\ndescription: \"Review and change Groovy scripting support (core/cas-server-core-scripting): ScriptResourceCacheManager, CompiledGroovyScript, inline vs external scripts, compiling off the request path, script security. Use when touching Groovy scripts or any module that runs them.\"\n---\n\n# Groovy and scripting\n\n## Groovy and scripting\n\n- Start at `core/cas-server-core-scripting`; roughly fifty modules delegate to the same five classes, so\n  changes there are changes to authentication, attribute release, MFA, AUP, OIDC, SAML and themes at once.\n- `ScriptResourceCacheManager` is a singleton whose `close()` clears the entire cache — it must never be\n  used in a try-with-resources block.\n- `CompiledGroovyScript.of` decides how a script runs: a stateless script (only final fields holding immutable\n  values) gets a fresh instance per call and runs in parallel; a stateful one keeps a shared instance serialized\n  on a blocking lock, on purpose, so its fields stay consistent. Do not report that lock as a bottleneck. Review every script-backed\n  decision for what a `null` result means and prefer fail-closed defaults at security boundaries — MFA\n  triggers and AUP read `null` as \"skip\". Never guard script execution with a timed `tryLock`; a timeout\n  that returns `null` is indistinguishable from a script that declined.\n- Do not compile scripts on a request path (`fromScript`/`fromResource` per call). Go through\n  `resolveScriptableResource`, or hold the compiled script in a transient field, and remember `fromResource`\n  also starts a file-watcher thread that nothing closes. The cache manager comes from\n  `ApplicationContextProvider` and is absent outside Spring, so always fall back to the factory instead of\n  throwing, or you will break components and tests that are constructed directly.\n- Inline (`groovy { ... }`) and external (`file:`/`classpath:`) scripts behave differently: only external\n  scripts honour `failOnError` and support reload, and only inline scripts support bindings (the\n  `setBinding` interface default is a no-op). Inline scripts swallow `GroovyRuntimeException`. Test both\n  forms, and cover bindings with concurrent and pooled-thread tests — `GroovyShellScriptTests.BindingTests`\n  is the pattern.\n- Treat compiling a Groovy script as running it: AST transformations execute inside the compiler. Script\n  text that comes from anywhere other than deployment configuration must be parsed\n  (`ScriptingUtils.validateGroovyScript`), never compiled.\n- Puppeteer coverage lives in the 18 `ci/tests/puppeteer/scenarios/*groovy*` scenarios\n  (`service-access-strategy-groovy`, `surrogate-login-groovy`, `mfa-provider-selection-trigger-groovy`,\n  `interrupt-afterauthn-groovy`, `webflow-groovy-action`, …). None of them exercise concurrency, script\n  errors, or cache expiry, so a change in this area needs new coverage rather than a rerun.\n",".claude/skills/cas-handoff/SKILL.md":"---\nname: cas-handoff\ndescription: \"Finish and hand off a change in the CAS repository: Checkstyle on changed Java files, no stale .git/index.lock, release-notes and puppeteer cross-checks, and the ready-to-use commit message. Use at the end of every task that changed files in cas-server, before reporting the work as done.\"\n---\n\n# Handing off CAS work\n\nRun these steps, in order, before reporting the work as done. The rules behind them follow.\n\n1. Compile what changed, main and test sources, with Error Prone, each changed module on its own class path so a\n   missing test dependency shows (see Conventions in AGENTS.md, the `cas-testing` skill and, in a sandbox without\n   Gradle, the `cas-sandbox-verification` skill).\n2. Run Checkstyle on every changed Java file (rule below), and fix overloads, imports and file length it reports.\n3. Run `git --no-optional-locks diff --check`.\n4. Cross-check the puppeteer scenarios that cover the change and update them (`cas-puppeteer-scenarios` skill).\n5. Add the release-notes entry when the work calls for one (security notes: see \"Test, concurrency, and hand-off\n   expectations\" in AGENTS.md; release-notes format: `cas-docs-site` skill).\n6. Record new, durable lessons in AGENTS.md or in the matching skill under `.claude/skills/`.\n7. Check `ls .git/*.lock` and clear what is left.\n8. End with the commit message (rule below). Say clearly what was not run.\n\n## Test, concurrency, and hand-off expectations\n\n- When the work is done, end the hand-off with a ready-to-use commit message covering only the uncommitted changes\n  (`git --no-optional-locks status`): a conventional subject (`fix:`, `feat:`, ... as in `git log`) and a short body.\n  The maintainer commits; do not commit yourself.\n\n## Environment limits\n\n- Leave no `.git/index.lock` behind. `git status` and `git diff` take that lock to refresh the index, and in a remote environment that cannot delete files the lock survives the command and blocks every subsequent git operation the maintainer runs. Read git state with `git --no-optional-locks status` / `git --no-optional-locks diff`, which never takes it, and before finishing check `ls .git/*.lock` and clear anything left. If deletion is refused, request it rather than leaving the repository wedged.\n- Maintainer: run Checkstyle on every changed Java file before handing work off, even without Gradle. Use the version in\n  `gradle/libs.versions.toml` (`checkstyle-<version>-all.jar` from the Checkstyle GitHub releases; 14.x needs Java 21+) as\n  `java -Dcheckstyle.suppressions.file=style/checkstyle-suppressions.xml -Dcheckstyle.importcontrol.file=style/import-control.xml\n  -jar checkstyle.jar -c style/checkstyle-rules.xml <files>`. When the workspace JDK is older, tar the files and `style/` into\n  `build/` (deletable without asking) and run it where Java 21 is available.\n",".claude/skills/cas-build-ci/SKILL.md":"---\nname: cas-build-ci\ndescription: \"Gradle build settings (configuration cache, Isolated Projects, build cache, test-category discovery) and the GitHub Actions workflows (tests.yml coverage job, setup-gradle caching, daemon reuse, CodeQL). Use when changing build logic under buildSrc or gradle/, the root Gradle settings, or anything under .github/workflows or ci/.\"\n---\n\n# CAS build and CI workflows\n\n## Workflows that matter here\n\n- Gradle 9.8.0 enables build caching, the configuration cache, parallel execution and Isolated Projects here.\n  Isolated Projects configures every project on a cache miss and ignores configuration on demand. Compare representative\n  scoped commands before changing either setting. User-home `gradle.properties` overrides the repository's JVM settings.\n- Test category discovery runs inside `TestCategoryTagsValueSource`: only its sorted tag list is a configuration input.\n  Implementation edits retain the cached graph; adding/removing categories invalidates it. Do not move source reads back\n  into the convention script or restore the old `.gradle/cas-test-tags` fingerprint cache.\n- An IDE `JetGradlePlugin` error about `setExcludedTaskNames` comes from the IDE integration, not category discovery.\n  Use `--no-isolated-projects` for the affected IDE invocation until that integration supports Isolated Projects; keep\n  configuration caching enabled and do not disable Isolated Projects globally to hide an IDE-only violation.\n\n## Practical boundaries\n\n- Unit-test matrix jobs (`tests.yml`) record JaCoCo data only (`testcas.sh --with-coverage` only turns on the agent) and upload the `.exec` files; the single `coverage` job builds `jacocoRootReport` and does every Sonar/Codecov/Coveralls/Codacy upload. Do not move `jacocoRootReport` or `sonar` back into the matrix: the root report compiles all ~430 projects and Sonar re-analyses the whole code base, which is what made each category job 17 minutes instead of 7.\n- Every `gradle/actions/setup-gradle` step sets `gradle-home-cache-excludes: caches/build-cache-1` and\n  `cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}`. Preserve both: the local build cache duplicates Develocity\n  outputs, and the encryption key enables saving/restoring the project configuration cache. Fork PRs without this secret\n  can still build, but cannot restore the encrypted configuration cache. Do not layer `setup-java` or GraalVM Gradle\n  caching over `setup-gradle` in the same job.\n- CI permits Gradle daemon reuse within a job; `ci/init-build.sh` must not append a disabling user-home property.\n  Release, documentation and scenario scripts preserve daemon reuse across their Gradle invocations. CodeQL keeps\n  `--no-daemon` for compiler tracing. Test categories and Puppeteer scenarios are fetched once per discovery\n  job; print the captured result and split scenario JSON with `jq`, preserving the existing matrix boundaries.\n- The CodeQL job (`analysis.yml`) applies `--no-build-cache --rerun-tasks` after its shared build options so traced\n  compilation actually runs. The current `settings.gradle` has no CodeQL-specific `JavaCompile` cache guard. Preserve\n  these workflow flags: CodeQL only extracts compilations it observes, so restored or up-to-date outputs empty the scan.\n",".claude/skills/cas-google-authenticator/SKILL.md":"---\nname: cas-google-authenticator\ndescription: \"Review and change Google Authenticator (GAuth) MFA credential repositories across backends (JPA, Redis, Mongo, DynamoDB, LDAP, JSON, REST, in-memory): owner-scoped deletes, encoding, counts, verification flags, the Redis index, crypto. Use when touching support/cas-server-support-gauth*.\"\n---\n\n# Google Authenticator MFA repositories\n\n## Google Authenticator credential repositories (all backends)\n\n- Review every backend against the same contract, with crypto on: `cas.authn.mfa.gauth.crypto.enabled` defaults to `true`, but every puppeteer scenario and the repository tests turn it off, so undecoded returns and double encryption never show up in CI.\n- A device delete must be scoped to its owner. WebAuthn, YubiKey and Duo device managers delete by username and id; `OneTimeTokenCredentialDeviceManager` and `GoogleAuthenticatorDeleteAccountAction` delete by id alone, and ids are guessable (JPA sequence; `currentTimeMillis` before 42bcde1).\n- `BaseOneTimeTokenCredentialRepository.encode()` mutates its argument. Never pass an object you will keep or save again; JPA `get(id)` and InMemory `get(id)` return stored, undecoded instances, and updating them double-encrypts.\n- `count()` means devices in Redis, JPA, Mongo and DynamoDB, but users in InMemory, JSON and LDAP; check which a caller assumes.\n- A repository read that fails must not look like \"no devices\": `OneTimeTokenAccountCheckRegistrationAction` sends empty or null results to enrollment (REST returns null, JSON returns empty on errors).\n- When testing deletes or updates, store devices for two users and two devices per user, and assert the others survive; single-user tests hid the DynamoDB `GE` filter and the LDAP update bug.\n- To make a range-filter bug visible in a DynamoDB delete test, pick keys that a wrong comparison would sweep up: the kept user's name sorts after the deleted one (\"z…\" vs \"a…\"), and the kept device ids are larger than the deleted id. Random UUIDs alone pass by luck half the time.\n- DynamoDB tables keyed by `id` should delete with `DeleteItem` on that key, not a scan followed by deletes.\n- Webflow actions and device managers that take a device id from the request must load it with the owner-scoped `get(principal.getId(), id)` and act only on what that returns. Unscoped `get(id)` / `delete(id)` are for the actuator and internal paths.\n- Every repository read returns a decoded copy (JPA detaches before decoding); `update()` must persist `properties`, which the verification flags of the delete and confirm actions live in.\n- The delete/confirm verification flags are stored on the account as `<flag>:<epoch millis>` and expire (`GoogleAuthenticatorAccountVerificationUtils`). They cannot live in flow or conversation scope: the login webflow keeps its execution state client-side, and the AJAX check and the form submit carry different execution keys.\n- `CasReentrantLock.tryLock(supplier)` returns null when the lock is not acquired; never let that null reach a caller as \"no accounts\".\n- `BaseOneTimeTokenCredentialRepository.encode()` returns an encoded copy and leaves its argument alone; store and return the copy, and read normalized values (lowercased username) from it, not from the argument.\n- Per-tenant ciphers must be built once and reused: a cipher built from settings with blank keys generates its own keys, so a new instance per call cannot decode what the previous one encoded. `BaseOneTimeTokenCredentialRepository` caches them by tenant and crypto settings.\n- WebAuthn FIDO metadata: `FidoMetadataDownloader.refreshBlob()` always downloads; `loadCachedBlob()` uses the cache until the BLOB's `nextUpdate`. CAS refreshes at startup and falls back to the cache on `IOException` (the FIDO service rate-limits with HTTP 429). `CompositeAttestationTrustSourceTests` downloads from the real service; a 429 there with no cache from an earlier run is an outage, not a regression.\n- Active Directory keeps a single value of `description` on user objects. The LDAP GAuth repository therefore writes all of an entry's accounts as one JSON array value; writing one value per account loses all but one device on AD.\n- A webflow action that validates a GAuth token and then updates the device must read the device again after validation: the validator updates its own copy (used scratch code, last-used time), and updating an older copy undoes that.\n\n## Google Authenticator Redis repository\n\n- Accounts live twice: the record at `CAS_TOKEN_ACCOUNT:<id>` and a copy as a member of the set `CAS_TOKEN_PRINCIPAL:<username>`. `get(username)`, `count(username)` and OTP validation read the set, not the record, so a delete that removes only the record leaves a device that is still listed and still validates.\n- Reads must not repair the index. A read that removes set members whose record \"does not exist\" acts on whatever the read saw; with `read-from` sending reads to replicas, and the record and the set on different cluster slots, a lagging replica makes a live device look orphaned and the removal on the primary is permanent. Repair belongs on the explicit `delete(id)`: when the record is missing the owner is unknown, so it scans `CAS_TOKEN_PRINCIPAL:*` in batches and removes members with that id.\n- Remove set members by the raw bytes Redis returned (`executePipelined(..., RedisSerializer.byteArray())` then `sRem`), not by re-serializing a deserialized account; the value serializer is LZ4 over JDK serialization, and a removal only matches if the bytes are identical.\n- Spring Data Redis pipelining works on Lettuce cluster connections (`LettuceClusterConnection` does not override `openPipeline`) and key `SCAN` spans all nodes, so pipelines and scans here are cluster-safe.\n",".claude/skills/cas-attribute-consent/SKILL.md":"---\nname: cas-attribute-consent\ndescription: \"Review and change attribute consent: ConsentDecision, the consent webflow gate, ConsentQueryResult, repositories and their lookups and deletes, decision decryption, dates and the consent scenarios. Use when touching support/cas-server-support-consent*.\"\n---\n\n# Attribute consent\n\n## Attribute consent review discipline\n\n- Start at `ConsentDecision`, not at a repository. `id` is a primitive `long` with\n  `@GeneratedValue`, so only JPA generates one. That single field is the key in four\n  backends: LDAP merges by it, Redis keys on `ConsentDecision:<principal>:<id>`, Mongo maps\n  it to `_id` by naming convention, and DynamoDB uses it as the table HASH key. It used to\n  stay `0` on every one of them, which collapsed each user (LDAP, Redis) or the whole\n  deployment (Mongo, DynamoDB) onto a single decision. `DefaultConsentDecisionBuilder.build`\n  now assigns a random positive id, JPA still replaces it with a generated one, and LDAP's\n  `mergeDecision` assigns one when `id <= 0`. Anything that creates a `ConsentDecision`\n  outside the builder -- the actuator's `/import`, say -- still arrives with id `0`.\n- `BaseConsentRepositoryTests` used to call `decision.setId(1/100/200)` before every store,\n  which is why the id defect never failed a test. `verifyMultipleDecisionsForPrincipal` now\n  stores builder-produced decisions for two users across two services and then deletes by id\n  and by principal, against every backend; `getOtherUser()` is overridden where the second\n  user must already exist (LDAP). The class holds a `@ResourceLock` because\n  `verifyDeleteRecordsForPrincipal` calls `deleteAll()` on the shared repository.\n- Consent is a gate in the login webflow, not a filter at attribute release.\n  `ConsentWebflowConfigurer` prepends `CheckConsentRequiredAction` to the login flow's\n  `generateServiceTicket` state; the only other consumer is\n  `SamlIdPConsentSingleSignOnParticipationStrategy`. Nothing in\n  `RegisteredServiceAttributeReleasePolicy` reads a stored decision, so the consented set\n  never constrains what is released, and any path that issues without the login flow --\n  REST protocol tickets, proxy tickets, OIDC refresh/userinfo, the OpenID4VCI\n  pre-authorized code grant -- releases attributes with no consent check at all.\n- Follow `ConsentQueryResult`. It carries the decision, the service and now the resolved\n  consentable attributes, and it used to be thrown away: `ConsentActivationStrategy` returned\n  a bare boolean and `CheckConsentRequiredAction` reduced it to an event, so\n  `prepareConsentForRequestContext` repeated the full `getConsentableAttributes` evaluation\n  (person directory included) and the repository read in the same request. The strategy now\n  returns the result and the action hands it to `prepareConsentForRequestContext`, which\n  resolves nothing when the result carries attributes -- a result built by a custom strategy\n  carries none and still falls back to resolving. What remains per consent interaction is one\n  evaluation and read for the prompt, one of each inside `storeConsentDecision` on confirm,\n  and one more from the `generateServiceTicketAfterConsent` clone that re-runs the check;\n  `getRegisteredServiceForConsent`, and with it the access strategy, still runs twice per\n  prompt. Count these by request before claiming a number.\n- `DefaultConsentEngine.executeRepositoryOperation` calls `toConsentRepository(tenant)` on\n  every operation, and `TenantJdbcConsentRepositoryBuilder` builds a DataSource plus a\n  Hibernate `EntityManagerFactory` inside it, then destroys them. Check the multitenant\n  path before judging consent performance; the single-tenant path is not representative.\n- Repository lookups must be checked for operator and pattern correctness, not just for\n  the right column names. `DynamoDbConsentFacilitator` matched SERVICE and ID with\n  `ComparisonOperator.GE` (correct for the date ranges other CAS DynamoDB facilitators\n  use it for, wrong for an equality lookup); it now uses `EQ`, but every read is still a\n  full `scanPaginator` because `id` is the table's only key. `RedisConsentRepository`\n  interpolated the raw principal into a SCAN glob; it now escapes glob characters and keeps\n  only keys whose remainder is a numeric id, because the `:` delimiter alone lets principal\n  `alice` reach the decisions of `alice:x`. Deleting one decision there is a `DEL` of the\n  exact key rather than a scan.\n- Deletes deserve the same reading as reads. `JpaConsentRepository.deleteConsentDecisions`\n  used `getSingleResult()` and so silently deleted nothing for any user with more than one\n  decision; it is a bulk JPQL delete now. `ChainingConsentRepository` -- which is what the\n  `consentRepository` bean always is, wrapping every configured store -- stored to every\n  repository but deleted with `anyMatch`, stopping at the first success; it now asks every\n  repository. `RestfulConsentRepository` sent `id` where the documented contract and the\n  test's own mock server say `decisionId`, so a conforming server read a single revocation\n  as \"revoke everything\". Revocation failing quietly is worse than consent failing loudly.\n- A decision that will not decipher used to be fatal: `getConsentableAttributesFrom` turned any\n  failure into `IllegalArgumentException`, which propagated out of `isConsentRequiredFor` and out\n  of the login flow, so key rotation or a node without the shared `cas.consent.core.crypto` keys\n  locked the user out. It now logs and returns no attributes, which reads as a mismatch and\n  re-prompts; the new decision is written back with the current keys. Weigh every failure on this\n  path the same way -- the login flow has no recovery for an exception thrown from the consent\n  check, so the question is always whether re-prompting is safe, not whether the error is correct.\n- `ConfirmConsentAction` parsed `option`, `reminder` and `reminderTimeUnit` straight off the request\n  with `Integer.parseInt` / `Long.parseLong` / `ChronoUnit.valueOf` and persisted them unvalidated, so\n  a missing parameter was a 500 on the confirm POST and `ChronoUnit.FOREVER` or `ERAS` parsed fine and\n  then threw from `createdDate.plus(...)` on every later login. Each value now falls back on the\n  configured default, and the pair is rejected unless it can actually be applied to a date -- checked by\n  applying it, since `LocalDateTime.isSupported` accepts `ERAS` and only the ISO era range rejects it.\n  `DefaultConsentEngine.calculateReminderExpirationDate` is the second half of that: a stored reminder\n  that cannot produce a date means consent is required, never an exception, which is what protects\n  decisions that were written before this validation existed or through the actuator's import.\n- `ConsentDecision.createdDate` is a zone-less `LocalDateTime` that one node writes and another compares\n  against its own clock, so it is produced with `ZoneOffset.UTC`, not `ZoneId.systemDefault()`. Before that,\n  the backends disagreed with each other: JPA, Redis, LDAP, DynamoDB and REST keep the wall-clock value\n  verbatim, so a node in another zone read a decision as hours older or newer, while MongoDB was accidentally\n  correct because Spring Data converts `LocalDateTime` through the system zone in both directions and so\n  normalizes to the instant. The reminder units offered in the consent view go down to seconds, so that skew\n  is not academic. `OneTimeToken.issuedDateTime` and the Google Authenticator token repositories still carry\n  the same pattern with second-scale windows.\n- `cas.consent.core.active` defaults to `true`, so adding the module turns consent on for\n  every service that releases attributes. Weigh findings about the consent path as if it\n  is always on.\n- Wallet/OID4VCI integration is the real gap, not a defect in the consent modules:\n  `BaseOidcVerifiableCredentialEncoder` reads `principal.getAttributes()` directly against\n  the issuer's configured claim list, so a credential handed to a wallet carries no\n  consent record, no per-claim choice, and no revocation. Selective disclosure exists in\n  the SD-JWT encoder but the holder never chooses what is disclosed at issuance time.\n- Puppeteer coverage: `attribute-consent` (in-memory repository, one user, one service),\n  `multitenancy-consent-jdbc`, `saml2-idp-login-consent`, `saml2-idp-login-consent-with-sso`.\n  No scenario exercises LDAP, Redis, Mongo, DynamoDB or REST consent, and none stores two\n  decisions for the same user, which is exactly the shape every storage defect above needs.\n",".claude/skills/cas-actuators/SKILL.md":"---\nname: cas-actuators\ndescription: \"Review and change CAS actuator endpoints: the security filter chain, access levels and IP access, REST actuator endpoints, exposure vs access, unmasked responses, request-path regexes, auditing and the actuator scenarios. Use when touching actuator endpoints or management security.\"\n---\n\n# Actuator endpoints\n\n## Actuator endpoints review discipline\n\n- There are ~63 endpoints. Inventory them with\n  `grep -rl --include=*.java -E \"@(RestControllerEndpoint|ControllerEndpoint|Endpoint|WebEndpoint)\\(id\"`;\n  `support/cas-server-support-reports-core` owns about a third, the rest are scattered across\n  feature modules. Review them as one attack surface, not one module at a time.\n- The whole security boundary is a single `SecurityFilterChain` built by\n  `CasWebSecurityConfigurerAdapter.configureHttpSecurity`. Rules are added in order --\n  ignored patterns (permitAll) -> each `CasWebSecurityConfigurer.configure` -> per-endpoint\n  `cas.monitor.endpoints.endpoint.*` -> `configureEndpointAccessToDenyUndefined` -> static\n  resources -> form login -- and Spring Security takes the first match, so anything a\n  `getIgnoredEndpoints` contributes wins over every endpoint rule that follows.\n- `prepareProtocolEndpoint` emits `/<ep>**` as well as `/<ep>/**`. The first form is a\n  within-segment wildcard, so an ignored endpoint also permits sibling paths sharing its\n  prefix. Check that before adding an entry.\n- That chain disables security headers and logout globally, and CSRF everywhere except the paths that configurers\n  declare through `getCsrfProtectedEndpoints()` (CIBA verification, WebAuthn). It is the same chain that serves the\n  actuator. Any browser-reachable session credential (`cas.monitor.endpoints.form-login-enabled`) therefore leaves\n  mutating actuator operations unprotected against cross-site requests.\n- `ActuatorEndpointProperties.access` defaults to `DENY` and the `endpoint` map always\n  carries a synthetic `defaults` key. The adapter iterates the map and calls\n  `EndpointRequest.to(\"defaults\")`, which resolves to no path and silently registers nothing;\n  the real default comes from `configureEndpointAccessToDenyUndefined`. Do not read the loop\n  as the place defaults are enforced.\n- `configureEndpointAccessToDenyUndefined` reads `management.endpoint.<rootPath>.access` with\n  a raw `Environment.getProperty` call and maps `UNRESTRICTED`/`READ_ONLY` to `permitAll`.\n  Relaxed spellings of that key are not seen (fail-closed), but the canonical spelling turns a\n  Boot-level exposure decision into anonymous Spring Security access.\n- `IP_ADDRESS` access is evaluated by `IpAddressAuthorizationManager`, which reads\n  `cas.audit.engine.alternate-client-addr-header-name` (default `X-Forwarded-For`) straight off the\n  request. This is intentional and is NOT a CAS defect: the fronting proxy, load balancer and\n  external boot configuration are expected to set that header as a trusted header, or to blank it\n  out and let Tomcat's own valves resolve the client address. Configuring that correctly is left to\n  the deployer, so do not report header spoofing here as a finding.\n- `BaseCasRestActuatorEndpoint` endpoints are NOT Spring Boot operations. They are discovered by\n  CAS's own `RestActuatorEndpointDiscoverer`, mapped by `RestActuatorEndpointHandlerMapping`, and\n  their handlers are Spring MVC mappings rather than `@ReadOperation`/`@WriteOperation` methods.\n  Boot applies an access level by filtering operations, and these endpoints declare none, so\n  `management.endpoint.<id>.access=READ_ONLY` does not stop their `@PostMapping`, `@PutMapping`,\n  `@PatchMapping` or `@DeleteMapping` handlers the way it stops a Boot write operation. That is\n  the gap `RestActuatorEndpointHandlerMapping.isMappingPermittedByAccessLevel` closes: it resolves\n  the endpoint's access through the `EndpointAccessResolver` bean and applies the same rules Boot\n  applies to operations, registering nothing under `NONE` and only `GET`/`HEAD`/`OPTIONS` mappings\n  under `READ_ONLY`. Note that `configureEndpointAccessToDenyUndefined` maps `READ_ONLY` to\n  `permitAll`, which is why the write methods used to get through, and maps `NONE` to `denyAll`, so\n  a `NONE` endpoint is usually refused by security before the missing mapping matters.\n  `ActuatorEndpointAccessControlTests` covers all of it, with `releaseAttributes` beside\n  `registeredServices` as the Boot operation endpoint that has always behaved this way.\n- Before changing what an access level does to these endpoints, measure the blast radius rather\n  than reasoning from the annotation: every REST actuator endpoint declares\n  `defaultAccess = Access.NONE` and the web application ships `management.endpoints.access.default=NONE`,\n  which suggests widespread breakage, but in fact all 44 test classes and all 69 puppeteer scenarios\n  that call one already declare `management.endpoint.<id>.access` or `management.endpoints.access.default`\n  explicitly. Walk the test tree and `ci/tests/puppeteer/scenarios/*/script.json` and count.\n- Do not confuse that with exposure. A REST actuator endpoint left out of\n  `management.endpoints.web.exposure.include` answers `403`, not `200`: no rule is registered for\n  it in `configureEndpointAccessToDenyUndefined`, and Spring Security denies a request no matcher\n  claims. Verify the status a given combination of exposure and access actually produces before\n  describing any of it as reachable.\n- When `management.server.port` differs, `CasCoreWebManagementContextConfiguration` registers the\n  handler mappings in the child management context but no `SecurityFilterChain`. Verify what\n  actually secures that port before assuming the main chain applies.\n- Several endpoints are authentication primitives, not diagnostics: `casValidate`,\n  `samlValidate` and `samlPostProfileResponse` mint CAS assertions and signed SAML responses\n  for an arbitrary username, and the `password` parameter is optional -- with no password they\n  fall through to `PrincipalResolver` and succeed. Anything that widens actuator access widens\n  impersonation. `tokenAuth` only validates a JWT and answers the principal with the service's\n  id, name and service id; it no longer issues tokens, and must never return service properties.\n- These endpoints return configuration values and service definitions unmasked, unlike Boot's own\n  `/env` and `/configprops`. `casConfig` `/retrieve` returns raw property values and\n  `registeredServices` serializes `OAuthRegisteredService.clientSecrets` (the `value` field carries\n  no `@JsonIgnore`). Masking was proposed and rejected, so do not re-propose it without agreeing\n  the approach with the maintainer first. `casConfig` `/encrypt` and `/decrypt` are a deliberate\n  operator tool that Palantir exposes in its UI, not an oracle to close.\n- Before proposing masking on any actuator response, work out how the admin UI consumes it: grep\n  `support/cas-server-support-thymeleaf/src/main/resources/static/js/palantir-*.js`. Palantir's\n  service editor does a full `GET /registeredServices/{id}` -> edit -> `PUT` round-trip\n  (`palantir-services.js`), so masking any path it reads would write the mask back into the\n  registry, and `/export` has the same requirement; redacting there needs the save path to preserve\n  existing secrets first. `casConfig` `/retrieve` does not have that problem, because\n  `palantir-pac4j.js` reads only `name` and `propertySource` from it.\n- User-supplied strings are compiled into regular expressions on the request path:\n  `configurationMetadata/{term}` (then matched over every known property in a `parallelStream`)\n  and `casConfig` `/retrieve`. Treat these as ReDoS/CPU-exhaustion boundaries; actuator endpoints\n  are not covered by CAS's authentication throttling.\n- No actuator endpoint carries `@Audit`. Session destruction, service deletion, key rotation,\n  configuration mutation and token minting all happen without an Inspektr record. Check for this\n  before claiming an administrative action is traceable.\n- Puppeteer coverage: `actuator-endpoint-cors`, `actuator-endpoint-login-jdbc`,\n  `actuator-endpoint-management`, `actuator-endpoint-metrics`, `actuator-endpoint-reports`,\n  `actuator-endpoint-roles`, `actuator-form-login`, `actuator-form-login-ldap`,\n  `actuator-form-login-ldap-roles`, `actuator-form-login-ldap-userauthz`,\n  `configuration-refresh-endpoint`, `sso-sessions-endpoint`, `tomcat-forwarded-headers`\n  (the only `IP_ADDRESS` scenario). Every one of them asserts that an *authorized* caller gets\n  200; none asserts 401/403 for an unauthenticated one, and 158 scenarios run with\n  `management.endpoints.web.exposure.include=*`. A change to endpoint security therefore needs a\n  new negative scenario rather than a rerun.\n",".claude/skills/cas-docs-site/SKILL.md":"---\nname: cas-docs-site\ndescription: \"Edit the CAS documentation site (docs/cas-server-documentation, Jekyll plugins, site.js/site.css, ci/docs/publish.sh, the docs data generator) and release notes: page titles and descriptions, setting links, settings rules, release-notes front matter and tags, Quick Start wizard, sidebar, mobile layout. Use when editing any documentation page or release_notes/RC*.md.\"\n---\n\n# CAS documentation site\n\n## Practical boundaries\n\n- Documentation pages on gh-pages are pre-rendered per version, but they load the stylesheet and script from the shared site root, and every publish overwrites those root files. The current design ships as `stylesheets/site.css`, `stylesheets/site-print.css` and `javascripts/site.js`; the root `stylesheet.css`, `print.css` and `main.js` belong to the released versions' old markup. A redesign that changes page markup must use new asset names, never replace the ones older versions link.\n- The documentation data generator (`docs/cas-server-documentation-processor`) runs as `java @build/casdocsgen.args ...`, an argument file written by its `docsGeneratorArguments` task from `sourceSets.main.runtimeClasspath`; `publish.sh` no longer builds the ~1 GB `casdocsgen.jar` boot jar. Its Gradle run adds `-DskipErrorProneCompiler=true` (override with `DOCS_GENERATOR_GRADLE_OPTIONS`, an empty value restores Error Prone). It still needs every CAS module compiled: actuators, feature toggles and shell commands are found by ClassGraph scans of the runtime classpath, and third-party settings come from the dependencies' metadata. Those lookups go through `CasDocumentationClassIndex`, one ClassGraph scan of `org` shared by the exporters; add new class lookups there rather than calling `ReflectionUtils`, which scans the whole classpath on every call.\n- `ci/docs/publish.sh` exit codes carry meaning for the workflow retry (`retry_on_exit_code: 1`): 1 is a failure worth another attempt, 3 is broken internal links/images/scripts (fails at once, nothing is published), 4 is broken external links only (the site is published first, then the job fails). Keep new failure paths on 1 unless a rerun cannot help. External links are checked only on the weekly schedule or when asked (`--proof-external`, the `proofReadExternal` input or `vars.DOCS_PROOF_EXTERNAL`), with successful results cached for 7 days in `build/htmlproofer`.\n- Actuator endpoint blocks (`_includes_site/actuators.html`) take their operations from the `cas_actuator_operations` filter in `_plugins/cas_actuators.rb` (sorted, parameters normalized, curl built there), and settings snippets from `cas_actuator_enable_snippet` / `cas_actuator_security_snippet` via `cas-actuator-snippet.html` in all three formats. Per operation, render only what differs (parameters, response, example); setup, security, settings and troubleshooting are rendered once per include in the shared tabs. The operations table is excluded from `responsiveTables()`.\n- Settings search: `ci/docs/index.js` writes `assets/data/<version>/index.json` as `{generated, docs}` (no lunr index; each doc carries name, short type, description, default, kind required/optional/thirdparty, module, duration, deprecation). `site.js` searches it directly (`searchCasSettings`, shared by the Configuration Properties page and the Shift-Shift palette); the page state lives in the URL (`?q=&exact=1&scope=name&kind=cas|thirdparty&deprecated=hide`). The layout exposes `data-docs-base`, `data-docs-version` and `data-docs-build` on `<body>` for these URLs.\n- Feature toggles (`Configuration-Feature-Toggles.md`) render through `_includes_site/cas-feature-toggles.html` from the `cas_feature_catalog` filter in `_plugins/cas_features.rb`, which derives feature and module from the `CasFeatureModule.<Feature>[.<module>].enabled` property, groups features by area and links docs only when the target page exists for that version. Add title overrides, groups or docs links there, not in the include. The environment variable form maps both `.` and `-` to `_` (`CasFeatureEnabledCondition` reads through `Environment.getProperty`), unlike the relaxed-binding form used for regular settings.\n- Module dependency tabs (`_includes_site/casmodule.html`, included through `include_cached`) are plain Bootstrap tabs: no inline script, the default tab and pane are chosen together in Liquid, and pane ids use the full coordinates plus the variant options. Do not add per-include scripts or truncated ids; either one lets the selected tab and the visible pane drift apart.\n- Local serving (`publish.sh --serve true`) keeps the configured `baseurl: /cas`, so the site answers at `http://localhost:4000/cas/<version>/` exactly like production. `variables.html` always sets `basePath` to `/cas` and `site.js` has no localhost path branches; do not reintroduce `--baseurl \"\"` or localhost-specific paths.\n- Release notes (`release_notes/RC<n>.md`) carry a `release:` front matter block (summary, facts, upgrade notes, highlights, optional spotlight over a front matter image list) and open with `{% include release-digest.html %}` and end with `{% include release-footer.html %}`. Tag New & Noteworthy topics with a block IAL on the line after the heading and Other Stuff bullets with a list item IAL (`- {: .fixed} ...`): types are `new`, `changed`, `fixed`, `action`, `removed`; add `data-area=\"...\"` only when the first link does not identify the area. The area must be a key of `AREAS` in `_plugins/cas_release_notes.rb` (SSO cookies and sessions are `tickets`; there is no `sso`). `_plugins/cas_release_notes.rb` normalizes the tags, groups Other Stuff by area and resolves `section:` names to kramdown GFM heading ids before rendering; a `section:` that names no heading logs a warning. Upgrade notes may point at an Other Stuff area group by its label. `release_notes/RC.md` (front matter `release_aggregate:`) is generated from every RC page in the same folder: topics with the same title are merged and show each release candidate's notes, identical Other Stuff items are listed once with all their release candidates, and the `<!-- release-changes -->` marker is where the generated changes go. Never edit changes into that page by hand.\n- Section heading icons come from `CAS_SECTION_ICONS` in `site.js` and apply only to direct `h2` children of the article whose id is in that map; add an entry there rather than icons in markdown.\n- In the development docs, `site.js` wraps every table inside `#cas-docs-container` in a framed `.table-scroll` card, so do not use `<table>` for layout inside components (buttons, headers, badges); use flex markup instead.\n- Documentation property blocks get their settings from the `cas_properties` / `cas_third_party_properties` filters in `docs/cas-server-documentation/_plugins/cas_properties.rb`; do not loop over `site.data` in Liquid for this, it scans ~14k entries per block. Liquid `assign` inside an include writes to the page scope, so a `casproperties` include nested inside another block's panels must pass `topics=\"false\"` (and usually `intro=\"false\"`) or it resets the outer block's state. Their output is wrapped in `{::nomarkdown}`, so include them at the start of a line in markdown or pass the capture through `markdownify` (with `|`, not `||`). Catalog descriptions carry raw `<`/`>` (for example `management.endpoint.<id>.access`); the plugin escapes every tag outside `INLINE_TAGS` into `descriptionHtml` / `summaryText`, so print those fields and never unescape descriptions in Liquid: one stray tag inside the actuator modal makes kramdown drop the closing tags and everything after it on the page.\n- Documentation page titles must be unique: use `title: CAS - <page H1>`, and give overview pages a `description:`\n  front matter entry (under 160 characters). Pages without one get their meta description from the first paragraph in\n  `_layouts/default.html`, which skips dependency boilerplate, short or colon-ended lead-ins and \"see this guide\" lines.\n- The Versions menu lists `documentation_versions` from `docs/cas-server-documentation/_config.yml` (development plus\n  maintained releases); update it together with the EOL schedule in `developer/Maintenance-Policy.md`.\n- Third-party CSS and JS in `_layouts/default.html` are pinned to exact versions with `integrity` (sha384) and\n  `crossorigin=\"anonymous\"`. Recompute the hash whenever a version changes; Google Fonts and the gtag script cannot carry one.\n  Analytics is the GA4 tag `G-W3M8JXP7GP`, loaded only on `apereo.github.io`.\n- Do not dim muted text with `opacity`: `--docs-muted` on its own meets 4.5:1 in both themes, and opacity drops it below.\n  `--docs-amber` is darker in light mode for the same reason.\n- In `casmodule.html`, only the tab links sit inside `role=\"tablist\"`; the Resources dropdown is a sibling `<button>`.\n- Mobile layout: content must never run past the 1.25rem gutter. A flex or grid child that holds long inline `code` needs\n  `min-width: 0`, and the code needs `overflow-wrap: anywhere` (`break-word` does not lower the min-content width, so the\n  item still overflows). Bootstrap `.row` blocks in content lose their negative margins below 760px. To check, load pages in\n  375px iframes and look for elements whose right edge passes `#cas-docs-container` outside a scrolling ancestor.\n- Release notes pages carry `docs-release-notes` on `<body>` (set in `_layouts/default.html` from the page URL); their prose,\n  footer included, is justified and hyphenated, while stats, facts, the release switcher and inline code are left alone.\n- Docs sidebar: `sidebar.md` stays a plain nested list; `buildSidebarTree()` in `site.js` turns it into the grouped tree. A\n  section's group comes from `CAS_SIDEBAR_GROUPS` (by the section's `#anchor`), so add a new top-level section there too; one\n  left out lands in the last group. A parent whose first child is an `Overview` link becomes that link (the row is dropped);\n  other parents only toggle. Only the current path opens, siblings close as another opens, open rows are sticky at\n  `--sidebar-filter-h + depth × --sidebar-row-h`, and `.docs-sidebar` has no top padding so the filter can pin at the very top.\n- Settings in docs (required, standing rule from the maintainer): never refer to a configuration setting by name in\n  documentation pages, neither inline nor in fenced `properties` blocks. Describe the feature, say that it is available,\n  and say that it is enabled, disabled or tuned through CAS settings; the page's `casproperties` include lists them. The\n  only exception is release notes, which may name CAS or other settings.\n- Setting links in release notes (required): every setting named in release notes must be marked so readers can look\n  it up in place. Inline, write `` `cas.server.name`{: .cas-setting} ``\n  (an assignment such as `` `cas.tgc.secure=false`{: .cas-setting} `` works too; the value is ignored for the lookup). For a\n  fenced `properties` block of settings, put `{: .cas-settings-linked}` on the line right after the closing fence. Mark only\n  real CAS or Spring setting names, not prefixes used as headings, removed settings, JSON fields or registered-service\n  properties. `initializeCasSettingLinks()` in `site.js` turns these into buttons that call `openCasSettingsPalette(name)`:\n  an exact match opens the setting's details inside the Shift-Shift palette, a partial name shows the matches; the page\n  never changes. Linked blocks are rebuilt line by line from their text (key, separator, value), because Rouge's\n  properties lexer splits keys with `[0]` into separate tokens. `casSettingsFlat()` drops anything in brackets and any\n  purely numeric name segment, so `[0]`, `[]`, the catalog's map notation `.[key]`, `.0.` and the environment variable form\n  `_0_` all match the same setting. No catalog name has a numeric segment of its own; recheck that if one is ever added. `planning/Quick-Start.md` is the reference page.\n- Quick Start wizard (`planning/Quick-Start.md`, `javascripts/quick-start.js`): the page needs exactly eight `## N. ` steps;\n  anything else on the page is an `###` inside one of them, or it shows on every step. `data-quick-feature` takes a\n  space-separated list and shows the section when any listed feature is selected. Each checkbox's `data-module` must match\n  the `casmodule` include in its recipe (the fieldset's `data-modules` holds the always-included ones): step 2's\n  Initializr command and step 6's `dependencies` block are built from them, and step 6's `cas.properties` from every\n  visible `.cas-settings-linked` block in steps 3–5, in page order, under the nearest heading. Generated blocks are\n  plain fenced blocks inside `[data-quick-output]`; the script rewrites their text and gutter and calls\n  `linkCasSettingsBlock()` from `site.js` again. The selection is page state: popstate restores the step only, and a link\n  into a recipe selects its first feature with a notice. Checkboxes may declare `data-requires` and `data-excludes`\n  (space-separated feature values; Interrupt requires Groovy, Redis and Hazelcast exclude each other, declared on both\n  sides): checking selects requirements and clears conflicts, clearing a feature clears its dependents, Check All adds\n  only what fits, and a selection from the address is normalized with earlier features winning. Test the wizard in\n  headless Chromium against a rendered copy (Back/Forward, deep links, rules, Check All output) after changes.\n- Every documentation page needs an opening paragraph right after the H1 that the layout can use as its description:\n  at least 40 characters, not ending in a colon, and not a \"see this guide\" or dependency lead-in.\n- On phones (`max-width: 760px`) the sticky header is offset by `--docs-masthead-height` so only the navigation bar\n  stays visible, and `site.js` sets `--docs-header-height` to that bar alone; keep both in step when changing the masthead.\n- The docs load two web fonts only, Bricolage Grotesque (headings) and DM Sans (everything else); `--font-mono` is the system\n  monospace stack. Do not add font families or hotlink images from third-party hosts; keep images under `images/`.\n- Large topics are split into one page per concern with a sidebar submenu (see `authorization/Heimdall-Authorization-*.md`\n  and `multitenancy/Multitenancy-*.md`); when moving a section, update cross-page anchors and links in release notes.\n\n## FIDO2 WebAuthn / passkeys review discipline\n\n- JSON examples pasted into the documentation are pretty-printed (one member per line, two-space indent), never minified.\n",".claude/skills/cas-import-pr/SKILL.md":"---\nname: cas-import-pr\ndescription: \"Bring an apereo/cas GitHub pull request onto a local branch without a git identity, keeping the contributor as author, by fetching each non-merge commit as a patch and applying it with git am. Use when asked to review, fix up or test a contributor PR locally.\"\n---\n\n# Importing a contributor pull request\n\n1. List the PR's commits (`https://api.github.com/repos/apereo/cas/pulls/<n>/commits`) and drop the\n   \"Merge branch 'master'\" commits.\n2. Cut a local branch from master.\n3. For each remaining commit, oldest first, fetch the patch and apply it as described below.\n4. Leave the result uncommitted beyond the contributor's own commits; the maintainer commits review fixes himself\n   (`cas-handoff` skill).\n\n## Environment limits\n\n- The remote shell has no git identity. To bring a contributor's PR onto a local branch, fetch each non-merge commit as a patch from `https://api.github.com/repos/apereo/cas/commits/<sha>` with `Accept: application/vnd.github.patch` and apply it with `git -c user.name=... -c user.email=... am --3way`, which keeps the contributor as author. Skip the \"Merge branch 'master'\" commits; the local branch is cut from master already.\n",".claude/skills/cas-interrupt-notifications/SKILL.md":"---\nname: cas-interrupt-notifications\ndescription: \"Review and change interrupt notifications: InterruptWebflowConfigurer, inquirers, the tracking cookie, passive requests, SSO reuse by protocol modules. Use when touching support/cas-server-support-interrupt*.\"\n---\n\n# Interrupt notifications\n\n## Interrupt notifications\n\n- Review the whole webflow path in `InterruptWebflowConfigurer`, not a single action: inquiry is wired at several\n  states, and the request-scoped finalized marker decides whether later checkpoints re-run the inquirers.\n- Treat the tracking cookie as a user-held acknowledgement. Changes to finalize/tracking must keep blocked\n  responses unacknowledgeable and must consider which principal the cookie was issued for.\n- Puppeteer interrupt scenarios often log out between logins, which removes the tracking cookie; a regression step for tracking must log in again without logging out.\n- Passive requests (CAS `gateway`, OIDC `prompt=none`, SAML `IsPassive`) must not render the interrupt view. When adding an event to an action that is prepended to an existing action state, add the matching transition to that state; an unmatched event silently continues to the next action.\n- A check that only runs in the login webflow is bypassed wherever a protocol module reuses the SSO session directly (for example the SAML2 IdP). For interrupts this is accepted for now; do not close it by vetoing SSO participation from the interrupt module without agreeing on the approach with the maintainer.\n- Inquirer errors currently fail open; any fix must state the intended behavior for blocking policies.\n- Reloadable inquirer state (files, watchers) must be published atomically; a failed reload keeps the last good contents rather than failing open.\n- Payloads map onto `InterruptResponse`, whose no-arg constructor means \"interrupt\". Parse external responses only after the status check, and test error bodies against random-port `MockWebServer` instances.\n",".claude/skills/cas-ldap/SKILL.md":"---\nname: cas-ldap\ndescription: \"Review and change LDAP integration: LdapUtils, connection factories and pools, search result handling, password transport, fail-open authorization, logging of directory data. Use when touching LDAP authentication, attribute repositories, password management or any LDAP-backed module.\"\n---\n\n# LDAP\n\n## LDAP review discipline\n\n- Start at the shared seam. `LdapUtils` builds connection configs, filters, authenticators, DN/entry resolvers\n  and person-attribute DAOs for every LDAP-backed feature; `LdapConnectionFactory` wraps every search, modify,\n  add, delete and password-modify. Findings there generalize; findings in one feature module usually do not.\n- Follow the whole flow: property model -> `LdapUtils` construction -> auto-configuration bean -> the calling\n  repository or handler -> the webflow/endpoint boundary that consumes the result. Most real defects live in\n  the mismatch between two of those layers, not inside one class.\n- Search results are a trust boundary. Check the LDAP result code, not just whether an entry came back, and\n  check what happens on truncation (server size/time/admin limits), on referral or continuation references,\n  and on multi-valued attributes that directories return in ranges.\n- Connection factories are pooled and stateful. Confirm they are created once as beans, that a pool used for\n  user binds is passivated back to its original bind state, and that maps of factories are keyed by something\n  that actually distinguishes two configurations.\n- For any code that reads or writes a password, verify the transport requirement against the current RFC text\n  and confirm the guard is enforcement rather than a log line; read the boolean carefully for inverted sense.\n- Treat a fail-open authorization branch as critical regardless of how it is reached. Trace the default value\n  of every property the branch depends on, including Spring Boot defaults CAS does not override.\n- Watch for values that are written by one path and compared by another, and for filter/template parameters\n  that are silently ignored when the operator's template does not reference them.\n- Secrets and directory internals must not reach logs or released attributes: password-reset answers, bind\n  credentials, diagnostic messages and matched DNs all deserve a second look.\n",".claude/skills/cas-heimdall-authzen/SKILL.md":"---\nname: cas-heimdall-authzen\ndescription: \"Review and change Heimdall authorization and its AuthZEN PDP endpoints (support/cas-server-support-heimdall*): request parsing, caller authentication, policies (JDBC, JSON resources), evaluations, PDP metadata, throttling and the heimdall-authzen/heimdall-nginx scenarios. Use when touching Heimdall code, docs or scenarios.\"\n---\n\n# Heimdall and AuthZEN\n\n## Heimdall / AuthZEN review discipline\n\n- Check the latest OpenID Authorization API final specification and editor's draft. AuthZEN is a PEP/PDP\n  protocol; wallet exchange uses OID4VCI/OID4VP. Treat a verified-presentation-to-policy bridge as a feature.\n- Trace `DefaultAuthorizationPrincipalParser` through `DefaultAuthorizationEngine` and each policy.\n  A valid caller token does not establish PDP audience, PEP permissions or DPoP proof possession;\n  delegated subject ids are legitimate for trusted PEPs and are not inherently impersonation.\n- An AuthZEN `resource.id` names the resource instance, never a policy namespace. AuthZEN requests\n  (`AuthorizationRequest.isAuthZen()`: subject, resource and action all present) match `resourceType`, `actions` and an\n  optional full-match `resourceIdPattern` across every namespace, and every match must grant. Normal requests\n  keep namespace + URI pattern + method and `/heimdall/authorize` rejects AuthZEN fields, which keeps the paths apart.\n- Callers authenticate with tokens issued to a registered OAuth/OIDC service whose access strategy allows\n  access; `HeimdallRegisteredServiceAccessStrategy` (alone or chained) can refuse it. DPoP and `x509_digest`\n  certificate bindings are enforced. `x509_digest` is the RFC 8705 `x5t#S256` thumbprint (base64url SHA-256 of the\n  DER certificate); compute it only with `OAuth20Utils.computeCertificateThumbprint` so issuer and verifiers agree. On AuthZEN, Basic means `client_id:client_secret` (reject clients without secrets:\n  `DefaultOAuth20ClientSecretValidator.validate` returns true when none is defined); on `/heimdall/authorize` it\n  stays CAS user credentials, split on the first colon. Both endpoints are throttled; `HeimdallThrottledHandlerInterceptor`\n  forwards only 401s to the throttle interceptors' post-processing. A `ThrottledRequestFilter` cannot limit what\n  counts: the default `httpPost()` filter claims every POST and the plan combines filters with `anyMatch`, and the\n  interceptors record every non-2xx response (twice: `postHandle` and `afterCompletion`; harmless for decisions,\n  since stores keep the latest failure per key or read the authentication audit trail).\n  Create registry-backed single-use markers through the `TicketFactory` (TST factory, custom `ExpirationPolicy` in\n  a *mutable* properties map: `buildExpirationPolicy` removes that key, so `Map.of` throws), never by instantiating\n  ticket implementations.\n- macOS JDKs use a polling `WatchService` (2s): a file created and deleted between two polls produces no event.\n  Watcher-driven caches must reconcile against the file system on every event rather than trust event kinds. JWT assertions need `jti`/`iat`, are single-use,\n  and share the token endpoint's audiences on purpose. A resource without policies denies.\n- JDBC trusted parameters (`principal`, `method`, `uri`, `namespace` and the AuthZEN names) are added after\n  context and attributes, so they already win; an overwrite claim there was a false finding. Check that policy\n  file deletion removes cached grants. `enforceAllPolicies=false` means any one policy grants: a failing policy is\n  logged and skipped, and failures are rethrown only when nothing grants (never turn them into a silent deny).\n  `enforceAllPolicies=true` stops at the first denial or failure. An empty policy list denies before either runs.\n- JDBC policies resolve their `DataSource` by bean name (`dataSourceName`, else `heimdallJdbcDataSource-<sha256(url|username)>`)\n  and register a `JpaBeans.newPoolingDataSource` pool (Hikari defaults, `minimumIdle=0`) through\n  `GenericApplicationContext.registerBean` under a lock, so Spring closes it on shutdown. Never cache a pool on a\n  policy instance (policies are rebuilt on every file reload) and never use `registerSingleton` for closeable beans:\n  Spring gives registered singletons no destruction callbacks. The pool key ignores the password (a rotated password\n  needs a new `dataSourceName` or a restart; documented). `queryTimeout` (default `PT5S`) is set on the `JdbcTemplate`. Tests override `resolveApplicationContext()` with a\n  local context rather than relying on the static `ApplicationContextProvider`, which parallel tests share.\n- `JsonAuthorizableResourceRepository` publishes one immutable `ResourceIndex` (by namespace and by AuthZEN\n  `resourceType`) per reload; AuthZEN lookups use the type index, so keep both maps in the same snapshot.\n- Policies evaluate sequentially; do not reintroduce `parallelStream()` (blocking policies starve `commonPool`).\n- AuthZEN subjects are resolved through the principal resolver only for subject type `user` (hardcoded);\n  other types become a bare principal. Policies read request data through\n  `AuthorizationRequest.resolveAttributeValues` (qualified `subject.*`, `resource.*`, `action.*`, `context.*` names);\n  never merge caller-supplied properties into principal attributes, which would let a PEP override the directory.\n  AuthZEN `context` is body-only; `/heimdall/authorize` adds non-protocol headers with `putIfAbsent`.\n- Palantir rebuilds resources from a fixed field list (`heimdallResourceForStorage`) and re-saves the whole\n  namespace; any new `AuthorizableResource` field must be added there or an edit silently drops it.\n- AuthZEN PDP metadata: the identifier is `<prefix>/heimdall`, served at `/cas/heimdall/.well-known/authzen-configuration`\n  (`HeimdallAuthZenConfigurationController`). The spec inserts the well-known segment after the host\n  (`/.well-known/authzen-configuration/cas/heimdall`), outside the CAS context: deployments need a proxy or ENGINE\n  rewrite-valve rule, as in the `heimdall-authzen` scenario's `rewrite.config`. `policy_decision_point` must equal the\n  identifier the PEP started from. List only endpoints that exist (no search yet).\n- AuthZEN decision context: denials carry `context.reason` from `AuthorizationDecisionReason` (set by the engine:\n  `subject_unresolved`, `no_matching_resource`, `no_policies`, `policy_denied`); grants carry no context. Never put\n  policy names, attribute names or messages in it; details go to DEBUG logs. Search (R5) and the OID4VP wallet bridge\n  (R7) are deferred and noted as future work in the Heimdall and verifiable credentials docs.\n- AuthZEN evaluations (`/heimdall/authzen/evaluations`): top-level subject/resource/action/context are defaults that an\n  entry replaces per key. Authenticate the caller once (`authenticateCaller`), then `resolveSubject` per entry;\n  never call `parse` per entry (it would consume single-use JWT assertions). Per-entry failures are `decision:false` with\n  `context.error.{status,message}` (no exception text for 500s); short-circuit semantics omit the remaining entries;\n  no/empty `evaluations` falls back to the single evaluation. Evaluations run sequentially.\n- AuthZEN evaluated denials use HTTP 200 with `decision:false`; authentication failures use 401.\n  Discovery/batch/search are separate capabilities, and the specification's example endpoint path is not mandatory.\n- Read shared helpers before reporting leaks: request headers already filter credentials and the request\n  principal is JSON-ignored. Confirm performance severity with evidence; blocking policy parallel streams\n  and unpooled JDBC merit investigation, not an unmeasured claim of outage.\n- Puppeteer scenario `heimdall-authzen` covers the AuthZEN endpoint end to end (decisions across namespaces,\n  id patterns, deny-wins, empty policies, `X-Request-ID`, unknown fields, 400/401, the Heimdall access strategy,\n  client-credential and bearer PEPs, the legacy endpoint, the actuator and throttling). It also enables throttling,\n  so keep 401-producing steps at least one throttle window apart. Palantir scenarios only check that tabs load.\n- Scenario `heimdall-nginx` drives the shared nginx config (`ci/tests/nginx`) as a PEP: `/api` uses `auth_request`\n  against `/heimdall/authorize`. The subrequest must set `proxy_method POST` (it is a GET otherwise) and send a\n  `namespace`; nginx does not escape variables in `proxy_set_body`, so URIs with `\"` or `\\` are rejected by a `map`.\n  `auth_request` maps any status other than 2xx/401/403 (such as Heimdall's 404) to 500. Validate edits with a local\n  `nginx -t` and stub upstreams; the config resolves `host.docker.internal` at load time.\n",".claude/skills/cas-mcp-server/SKILL.md":"---\nname: cas-mcp-server\ndescription: \"Review and extend the CAS MCP server (support/cas-server-support-mcp): @McpTool beans, security and auditing, optional tool groups, typed results and output schemas, secret redaction, resources and completion, tests and the mcp-server-* scenarios. Use when adding or changing MCP tools, resources or prompts.\"\n---\n\n# CAS MCP server module\n\n## MCP server module (`support/cas-server-support-mcp`)\n\n- Tools are plain beans whose methods carry `@McpTool` plus `@PreAuthorize(\"hasRole('MCP_ADMIN')\")`;\n  `CasMcpToolsTests.verifyToolsAreSecuredByProxies` asserts every such bean is an AOP proxy. Read tools need `MCP_ADMIN`.\n- Tools that change CAS (`AI.mcp-write`, off by default) live in separate `*AdministrationTools` beans so the toggle can drop\n  them: core ones in `CasAdministrationTools` (sessions with SLO through `SingleLogoutRequestExecutor`, throttle release via\n  `ThrottledSubmissionsStore.removeIf`, `setServiceEnabled` on `DefaultRegisteredServiceAccessStrategy` including chained\n  ones, `setLogLevel` through Spring Boot's `LoggingSystem`), optional ones as extra `@Bean` methods in the group's nested\n  configuration, or a nested configuration of their own (`CasMcpOAuthAdministrationConfiguration`). Each bean method carries\n  `@ConditionalOnFeatureEnabled(module = \"mcp-write\", enabledByDefault = false)`, each tool `hasRole('MCP_OPERATOR')`,\n  `readOnlyHint = false` and `destructiveHint = true` unless harmless. They accept session references and record ids from\n  the read tools, never raw TGT or token ids. Tests: `CasMcpToolsTests` turns `mcp-write` on (bean level, `MCP_OPERATOR`\n  mock user, Mockito `verify` on the mocked MFA repositories), `CasMcpServerTests.WriteToolsTests` (MockMvc, `mcpoperator`,\n  tokens), and `ProtocolTests` asserts they are not listed by default. Checkstyle rejects `public LoggerXxx` and a parameter\n  named `logger` (non-static logger rule).\n- A tool over an optional feature follows the consent pattern: `compileOnly` (and `testImplementation`) on that module, a\n  nested `@Configuration` with `@ConditionalOnClass` and feature module `mcp-<group>`, an `ObjectProvider` for the backing\n  bean and a `reason` in the result when it is absent; test activation in `CasMcpAutoConfigurationTests`.\n- Never return secrets: device fingerprints, trusted-device record keys, client secrets, key material, setting values.\n  Tests assert the secret string is absent from the result.\n- A turned-off events repository is a `BeanSupplier` proxy, not null: check `BeanSupplier.isProxy`.\n- Every `@McpTool`/`@McpResource` call is audited by one infrastructure advisor (`CasMcpAuditInterceptor`, ordered before\n  method security so denials are recorded) that hands a `CasMcpOperation` (an `AuditableEntity`, principal = the MCP\n  user) to the `@Audit`-annotated `CasMcpAuditor`. New tools are covered automatically; never add `@Audit` to tool\n  methods (the default audit principal resolver would take the first argument, the person being looked up).\n- Tools summarize; they never return serialized CAS objects (tickets, services' secrets) or attribute values outside\n  findPerson/previewAttributeRelease. Ticket-granting ticket ids are bearer tokens: return\n  `CasMcpSessionReferences.of(id)` and accept references back through `CasMcpSessionReferences.resolve` (with the\n  username). Service JSON from findService/matchService goes through the secret redaction in `CasServiceManagementTools`.\n- MCP capabilities are set in `webapp/cas-server-webapp-resources/src/main/resources/application.properties`\n  (`spring.ai.mcp.server.capabilities.*`) and mirrored in `BaseCasMcpTests`; a `@McpResource` or `@McpPrompt` bean is\n  silently not advertised while its capability is false. Resources (`org.apereo.cas.mcp.resources`) return JSON built\n  with a plain `JsonMapper`: CAS's `JacksonObjectMapperFactory` mappers use `NON_DEFAULT` inclusion and drop `false`/`0`.\n- `DefaultAuthenticationRiskEvaluator` is `@Transactional` on `transactionManagerEvents`; test contexts that use a real\n  evaluator need that bean (`PseudoTransactionManager`, as `CasCoreEventsAutoConfiguration` registers it).\n- Tools that evaluate a person at an application build their subject with `CasMcpEvaluationContextFactory.build`\n  (service, matching registered service, resolved principal, `authentication()`); request-aware components get\n  `currentHttpExchange()`, the live request with parameters, headers, cookies and query string hidden, or\n  `newWebflowRequestContext(context)`, a webflow request context over it with service, registered service, credential\n  and authentication in scope. Do not resolve services or principals by hand in a tool. Tools that need client details\n  (IP address, user agent, location) take them as parameters and wrap them in `CasMcpClient`: `ClientInfoHolder`\n  describes the MCP operator. `currentHttpExchange(client)` exposes them as remote address, `User-Agent` and the\n  `geolocation` parameter; `withClientInfo(client, ...)` swaps `ClientInfoHolder` only around components that read it\n  (MFA triggers). Do not swap it around audited calls such as the risk evaluator, or the audit trail records the\n  supplied address instead of the operator's.\n- A new tool also goes into `CAS_TOOLS` (`CasMcpServerTests`, `mcp-server-tools/script.js` when core), the role-denial\n  lists, `FeatureDisabledTests`, and the tables and feature-toggle list in `Admin-MCP-Server.md`. A new optional group also\n  goes into `CasDeploymentTools.MCP_TOOL_GROUPS` (feature module to bean name), which `describeDeployment` reports.\n- Tools return typed records with `@McpTool(generateOutputSchema = true)` (only findService/matchService return free-form\n  JSON strings): tools/list then carries an `outputSchema` and calls return `structuredContent`, which the MCP SDK validates\n  against the schema and copies into the text content. Mark optional components `@Nullable` (jspecify) and annotate the\n  record `@JsonInclude(NON_NULL)`: non-null components are `required` in the schema and a `null` fails validation. Never\n  return `null` from a typed tool; return the record with a `reason`. Keep JSON names stable (puppeteer reads them), use\n  `String` for dates, `Object` for nested CAS objects whose shape should not become schema, `@JsonProperty` for Java\n  keywords such as `default`, and Lombok `@Builder` for records with many optional components. Bean-level tests use the\n  record accessors.\n- A tool that combines others (explainLogin) calls the other tool beans and converts their records to maps with a plain\n  `JsonMapper`. `CasMcpAuditInterceptor` binds a `ScopedValue` for the outermost call, so nested tool calls are not audited.\n- Puppeteer builds CAS with `-DskipNestedConfigMetadataGen=true`, so the settings catalog at runtime holds only third-party\n  (Spring) settings and no `cas.*` ones. Scenarios exercise settings tools with a Spring setting such as\n  `spring.ai.mcp.server.name`; `cas.*` settings and the `module` filter are covered by unit tests (`McpSettingsMetadata.json`).\n- MCP completion (`@McpComplete`) only completes resource-template and prompt arguments, never tool parameters. Completion\n  methods carry `@PreAuthorize` like tools and are not audited; `spring.ai.mcp.server.capabilities.completion` must be true.\n- Claude's shells have no JDK 25 or Maven Central; `build/mcp-run.sh` compiles, runs checkstyle, the `RestfulApi` tests\n  and the MCP puppeteer scenarios on the Mac.\n- OAuth resource checks use `OAuth20RequestParameterResolver.resolveAllowedResources(service)`, which adds the CAS-wide\n  `cas.authn.oauth.core.allowed-resources` (default: the CAS MCP endpoint) to the service's `allowedResources`; never\n  match against `getAllowedResources()` alone. `AbstractOAuth20Tests` and `BaseCasMcpTests` allow `https://cas.example.org/cas/mcp`.\n- Bearer access tokens (`AI.mcp-oauth`, nested `CasMcpOAuthConfiguration`, needs OIDC for the issuer): the filter\n  `CasMcpBearerTokenAuthenticationFilter` is built inside a `CasWebSecurityConfigurer`, never exposed as a bean (Spring Boot\n  registers `Filter` beans for every request). It sits before `BasicAuthenticationFilter`, acts only on the MCP path with a\n  `Bearer` header, sets a `CasMcpBearerTokenAuthentication` (principal id, client id, scopes) without saving it, and grants\n  `ROLE_MCP_ADMIN` only from `cas.mcp.oauth.required-attributes` (empty matches nobody), and `ROLE_MCP_OPERATOR` only with the\n  `mcp:write` scope plus `cas.mcp.oauth.operator-required-attributes` (`grantRoles`), checked against attributes resolved\n  through `defaultPrincipalResolver` (token principal as current principal): a token's principal carries only the attributes\n  released to the client, so `memberOf` and the like are usually missing there. The unauthenticated challenge comes from\n  `defaultAuthenticationEntryPointFor(..., mcp matcher)`, registered before `httpBasic` initializes so it wins. Being first\n  also makes it Spring Security's fallback for requests no entry point claims (browser requests to actuators), so outside\n  the MCP path it answers with the Basic challenge alone. A configurer must never set an explicit `authenticationEntryPoint`,\n  which replaces every other one; Palantir's CAS login registers its entry point with `defaultAuthenticationEntryPointFor`\n  for `/palantir/**` only (`DashboardControllerTests.CasAuthenticationTests`). The security context is saved in a request attribute only (async SSE dispatches need\n  it), never in the HTTP session.\n- Spring Security's Basic challenge is `Basic realm=\"Realm\", charset=\"UTF-8\"` (MockMvc reports it as more than one value), so\n  `CasMcpAuthenticationEntryPoint` delegates to `BasicAuthenticationEntryPoint` rather than writing it, and tests compare with\n  that entry point's output (`CasMcpServerTests.basicChallenge`). `jsonPath(...).value(List.of(...))` reads `null` because the\n  expected value's class (`List.of`'s immutable type) becomes the target type; assert `[0]` and `length()` instead. Protected resource metadata lives under the CAS\n  context (`/cas/.well-known/oauth-protected-resource/mcp`), because the root well-known path is outside the web app; clients\n  reach it through `resource_metadata`. Tests build `OAuth20DefaultAccessToken` directly and add it to the registry\n  (`CasMcpServerTests.newAccessToken`); `mcp-server-saml-oidc` gets a JWT token through the password grant.\n- `CasMcpEvaluationContextFactory` hands the username to the principal resolvers as the current principal. Without attribute repositories the chain holds only `EchoingPrincipalResolver`, which echoes the current principal, so resolving without one yields `NullPrincipal` (`nobody`).\n- OAuth/OIDC services serialize their secret as `clientSecrets` (a list of objects with `value`); `getClientSecret()` is `@JsonIgnore`. Redaction of service definitions must cover the plural property, which `SECRET_PROPERTY` in `CasServiceManagementTools` does with `secrets?`.\n- `CasOidcTools.checkOidcClientResource` reuses `RegisteredServiceOAuthResourceMatcher` (`isValidResource`,\n  `findAllowedResource`) so the tool and the protocol endpoints decide resource indicators the same way; new tools are\n  added to `CasMcpServerTests.CAS_TOOLS` and to the optional-tool lists of the `mcp-server-*` scenarios.\n",".claude/skills/cas-oauth-oidc-authentication/SKILL.md":"# cas-oauth-oidc-authentication\n\nPath: `.claude/skills/cas-oauth-oidc-authentication/SKILL.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/apereo/cas/HEAD/.claude/skills/cas-oauth-oidc-authentication/SKILL.md)",".claude/skills/cas-oauth-oidc/SKILL.md":"# cas-oauth-oidc\n\nPath: `.claude/skills/cas-oauth-oidc/SKILL.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/apereo/cas/HEAD/.claude/skills/cas-oauth-oidc/SKILL.md)",".claude/skills/cas-oid4vc/SKILL.md":"# cas-oid4vc\n\nPath: `.claude/skills/cas-oid4vc/SKILL.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/apereo/cas/HEAD/.claude/skills/cas-oid4vc/SKILL.md)",".claude/skills/cas-passwordless-webauthn/SKILL.md":"# cas-passwordless-webauthn\n\nPath: `.claude/skills/cas-passwordless-webauthn/SKILL.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/apereo/cas/HEAD/.claude/skills/cas-passwordless-webauthn/SKILL.md)",".claude/skills/cas-protocol/SKILL.md":"# cas-protocol\n\nPath: `.claude/skills/cas-protocol/SKILL.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/apereo/cas/HEAD/.claude/skills/cas-protocol/SKILL.md)",".claude/skills/cas-puppeteer-scenarios/SKILL.md":"# cas-puppeteer-scenarios\n\nPath: `.claude/skills/cas-puppeteer-scenarios/SKILL.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/apereo/cas/HEAD/.claude/skills/cas-puppeteer-scenarios/SKILL.md)",".claude/skills/cas-saml/SKILL.md":"# cas-saml\n\nPath: `.claude/skills/cas-saml/SKILL.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/apereo/cas/HEAD/.claude/skills/cas-saml/SKILL.md)",".claude/skills/cas-sandbox-verification/SKILL.md":"# cas-sandbox-verification\n\nPath: `.claude/skills/cas-sandbox-verification/SKILL.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/apereo/cas/HEAD/.claude/skills/cas-sandbox-verification/SKILL.md)",".claude/skills/cas-service-registries/SKILL.md":"# cas-service-registries\n\nPath: `.claude/skills/cas-service-registries/SKILL.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/apereo/cas/HEAD/.claude/skills/cas-service-registries/SKILL.md)",".claude/skills/cas-session-stores/SKILL.md":"# cas-session-stores\n\nPath: `.claude/skills/cas-session-stores/SKILL.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/apereo/cas/HEAD/.claude/skills/cas-session-stores/SKILL.md)",".claude/skills/cas-startup-performance/SKILL.md":"# cas-startup-performance\n\nPath: `.claude/skills/cas-startup-performance/SKILL.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/apereo/cas/HEAD/.claude/skills/cas-startup-performance/SKILL.md)",".claude/skills/cas-storage-backends/SKILL.md":"# cas-storage-backends\n\nPath: `.claude/skills/cas-storage-backends/SKILL.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/apereo/cas/HEAD/.claude/skills/cas-storage-backends/SKILL.md)",".claude/skills/cas-testing/SKILL.md":"# cas-testing\n\nPath: `.claude/skills/cas-testing/SKILL.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/apereo/cas/HEAD/.claude/skills/cas-testing/SKILL.md)",".claude/skills/cas-ticket-registries/SKILL.md":"# cas-ticket-registries\n\nPath: `.claude/skills/cas-ticket-registries/SKILL.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/apereo/cas/HEAD/.claude/skills/cas-ticket-registries/SKILL.md)",".claude/skills/cas-ui/SKILL.md":"# cas-ui\n\nPath: `.claude/skills/cas-ui/SKILL.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/apereo/cas/HEAD/.claude/skills/cas-ui/SKILL.md)",".claude/skills/cas-web-security/SKILL.md":"# cas-web-security\n\nPath: `.claude/skills/cas-web-security/SKILL.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/apereo/cas/HEAD/.claude/skills/cas-web-security/SKILL.md)","AGENTS.md":"# AI Agent Protocol & Instructions\n\nPath: `AGENTS.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/apereo/cas/HEAD/AGENTS.md)","CLAUDE.md":"# Claude Agent Guidelines & System Prompt\n\nPath: `CLAUDE.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/apereo/cas/HEAD/CLAUDE.md)",".github/copilot-instructions.md":"# GitHub Copilot Instructions\n\nPath: `.github/copilot-instructions.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/apereo/cas/HEAD/.github/copilot-instructions.md)",".cursorrules (testrail-test-case-cursorrules-prompt-file.mdc)":"---\ndescription: \"Cursor rules for TestRail development with test case integration.\"\nglobs: **/*\nalwaysApply: false\n---\n# Persona\n\nYou are an experienced QA Engineer with expertise in writing clear, detailed test cases\nfor TestRail that help testers efficiently execute tests and validate functionality.\nYou understand testing methodologies and how to structure test cases that provide\ncomprehensive coverage while remaining maintainable and reusable.\n\n# Documentation Focus\n\nFocus on creating standardized TestRail test cases with these key components:\n\n- Clear, descriptive test case title\n- Concise test case objective/purpose\n- Preconditions required for test execution\n- Step-by-step test procedure with expected results\n- Test data requirements\n- Post-conditions after test execution\n- Appropriate custom fields for TestRail\n\n# Best Practices\n\n**1** **Clear Title**: Create descriptive, specific test case titles\n**2** **Complete Preconditions**: List all necessary setup requirements\n**3** **Numbered Steps**: Present test steps in a clear, sequential order\n**4** **Explicit Expected Results**: Specify precise expected outcomes for each step\n**5** **Appropriate Test Type**: Assign the correct test type (functional, regression, etc.)\n**6** **Test Data**: Include specific test data values where applicable\n**7** **Environment Details**: Specify relevant environment information\n**8** **Organized Sections**: Group related test cases in logical sections\n\n# TestRail CSV Format Example\n\n```csv\nTitle,Section,Type,Priority,Preconditions,Steps,Expected Results\n\"Login with Valid Credentials\",\"User Authentication\",\"Functional\",\"Critical\",\"User has valid account credentials\",\"1. Navigate to the login page.\n2. Enter valid username 'testuser'.\n3. Enter valid password 'Password123'.\n4. Click the 'Login' button.\",\"1. Login page loads correctly.\n2. Username field accepts input.\n3. Password field accepts input.\n4. User is successfully logged in and redirected to the dashboard.\"\n\"Login with Invalid Password\",\"User Authentication\",\"Functional\",\"High\",\"User has valid account credentials\",\"1. Navigate to the login page.\n2. Enter valid username 'testuser'.\n3. Enter invalid password 'wrongpassword'.\n4. Click the 'Login' button.\",\"1. Login page loads correctly.\n2. Username field accepts input.\n3. Password field accepts input.\n4. Error message is displayed: 'Invalid username or password'.\"\n\"Password Reset Request\",\"User Authentication\",\"Functional\",\"Medium\",\"User has registered email address\",\"1. Navigate to the login page.\n2. Click the 'Forgot Password' link.\n3. Enter valid email 'test@example.com'.\n4. Click the 'Reset Password' button.\",\"1. Login page loads correctly.\n2. Forgot password page loads correctly.\n3. Email field accepts input.\n4. Success message is displayed: 'Password reset link sent'.\"\n```\n\n# Converting Automated Tests to TestRail Format\n\nWhen converting automated tests or feature descriptions to TestRail format:\n\n1. Identify the overall test objective and create a descriptive title\n2. Extract preconditions from setup code or implied requirements\n3. Convert test actions into numbered steps\n4. Transform assertions into expected results\n5. Group related test cases into sections\n6. Assign appropriate test types and priorities\n7. Include specific test data values\n8. Add environmental notes if necessary\n\nExample:\n\nAutomated Test:\n\n```js\ndescribe('Login Functionality', () => {\n  it('should allow login with valid credentials', () => {\n    cy.visit('/login');\n    cy.get('#username').type('testuser');\n    cy.get('#password').type('Password123');\n    cy.get('#loginButton').click();\n    cy.url().should('include', '/dashboard');\n    cy.get('.welcome-message').should('contain', 'Welcome, testuser');\n  });\n});\n```\n\nTestRail CSV Format:\n\n```csv\nTitle,Section,Type,Priority,Preconditions,Steps,Expected Results\n\"Login with Valid Credentials\",\"User Authentication\",\"Functional\",\"Critical\",\"User has valid account credentials\",\"1. Navigate to the login page.\n2. Enter valid username 'testuser'.\n3. Enter valid password 'Password123'.\n4. Click the 'Login' button.\",\"1. User is redirected to the dashboard page.\n2. Welcome message is displayed: 'Welcome, testuser'.\"\n```\n\n# Test Case Structure\n\nStructure TestRail test cases using this format:\n\n```\n# Test Case: [Descriptive title]\n\n## Section\n[Section/Module/Feature]\n\n## Priority\n[Critical/High/Medium/Low]\n\n## Type\n[Functional/Regression/Usability/Performance/Security/etc.]\n\n## Objective\n[Clear statement of what the test aims to verify]\n\n## Preconditions\n1. [Precondition 1]\n2. [Precondition 2]\n...\n\n## Test Data\n- [Test data item 1: value]\n- [Test data item 2: value]\n...\n\n## Steps and Expected Results\n| # | Step | Expected Result |\n|---|------|----------------|\n| 1 | [Action to perform] | [Expected outcome] |\n| 2 | [Action to perform] | [Expected outcome] |\n...\n\n## Post-conditions\n1. [Post-condition 1]\n2. [Post-condition 2]\n...\n\n## Automation Status\n[Not Automated/To Be Automated/Automated]\n\n## References\n- [Requirement ID/User Story/Documentation Link]\n```\n\n# Example Test Case\n\nHere's an example of a well-structured TestRail test case:\n\n```\n# Test Case: User Login with Valid Credentials\n\n## Section\nAuthentication\n\n## Priority\nHigh\n\n## Type\nFunctional\n\n## Objective\nVerify that a user can successfully log in to the application using valid credentials.\n\n## Preconditions\n1. The application is accessible\n2. The test user account exists in the system\n3. The user is not currently logged in\n\n## Test Data\n- Username: test_user@example.com\n- Password: Test@123\n- User Role: Standard User\n\n## Steps and Expected Results\n| # | Step | Expected Result |\n|---|------|----------------|\n| 1 | Navigate to the login page | The login page is displayed with username and password fields, and a login button |\n| 2 | Enter valid username \"test_user@example.com\" in the username field | Username is accepted and displayed in the field |\n| 3 | Enter valid password \"Test@123\" in the password field | Password is accepted and masked in the field |\n| 4 | Click the \"Login\" button | The system authenticates the user and redirects to the dashboard |\n| 5 | Verify user information displayed in the header/profile section | Username \"test_user@example.com\" is displayed correctly |\n\n## Post-conditions\n1. User is logged in to the application\n2. User session is created\n3. User can access functionality based on their permissions\n\n## Automation Status\nAutomated\n\n## References\n- Requirement: REQ-AUTH-001\n- User Story: US-102\n```\n\n# Negative Test Case Example\n\nHere's an example of a negative test case:\n\n```\n# Test Case: User Login with Invalid Password\n\n## Section\nAuthentication\n\n## Priority\nHigh\n\n## Type\nFunctional\n\n## Objective\nVerify that the system correctly handles login attempts with an invalid password.\n\n## Preconditions\n1. The application is accessible\n2. The test user account exists in the system\n3. The user is not currently logged in\n\n## Test Data\n- Username: test_user@example.com\n- Password: WrongPassword123\n- User Role: Standard User\n\n## Steps and Expected Results\n| # | Step | Expected Result |\n|---|------|----------------|\n| 1 | Navigate to the login page | The login page is displayed with username and password fields, and a login button |\n| 2 | Enter valid username \"test_user@example.com\" in the username field | Username is accepted and displayed in the field |\n| 3 | Enter invalid password \"WrongPassword123\" in the password field | Password is accepted and masked in the field |\n| 4 | Click the \"Login\" button | The system displays an error message \"Invalid credentials. Please try again.\" |\n| 5 | Verify the user remains on the login page | The login page is still displayed with empty password field |\n| 6 | Verify the username field retains the entered username | Username \"test_user@example.com\" is still displayed in the field |\n\n## Post-conditions\n1. User remains logged out\n2. No user session is created\n3. Failed login attempt is logged in the system\n\n## Automation Status\nAutomated\n\n## References\n- Requirement: REQ-AUTH-002\n- User Story: US-103\n```\n\n# TestRail Specifics\n\nKeep these TestRail-specific considerations in mind:\n\n1. TestRail supports custom fields that may be specific to your organization\n2. TestRail allows for organization of test cases into sections and sub-sections\n3. Test cases can be added to test plans and assigned to testers\n4. TestRail allows for recording of test results and defects\n5. Automation status is often a key field for tracking automation coverage\n6. References to requirements, user stories, or other artifacts help with traceability\n\n# Test Case Writing Best Practices\n\nWhen writing TestRail test cases, follow these best practices:\n\n1. Use clear, descriptive titles that summarize what is being tested\n2. Write steps that are atomic, specific, and contain a single action\n3. Specify expected results for each step, not just the final outcome\n4. Include all necessary preconditions to ensure test reproducibility\n5. Specify concrete test data rather than vague descriptions\n6. Make test cases independent and self-contained when possible\n7. Use consistent language and terminology across all test cases\n8. Create reusable test cases that can be part of multiple test plans\n9. Include both positive and negative test scenarios\n10. Consider boundary values, equivalence partitions, and edge cases\n\n# Test Case Adaptation\n\nAdapt your test cases based on:\n\n- The specific product or feature being tested\n- Project-specific TestRail custom fields\n- Team-specific test case organization and naming conventions\n- Integration requirements with other tools (JIRA, DevOps, etc.)\n- Automation needs and frameworks\n\nWhen creating test cases, focus on providing clear guidance to testers\nwhile ensuring comprehensive coverage of functionality and edge cases.\n"},"items":[{"name":"AGENTS.md","path":"AGENTS.md","rawUrl":"https://raw.githubusercontent.com/apereo/cas/HEAD/AGENTS.md","title":"AI Agent Protocol & Instructions","category":"root-instruction","format":"markdown","content":"# AI Agent Protocol & Instructions\n\nPath: `AGENTS.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/apereo/cas/HEAD/AGENTS.md)","tokens":300,"sizeBytes":144},{"name":"SKILL.md","path":".claude/skills/cas-actuators/SKILL.md","rawUrl":"https://raw.githubusercontent.com/apereo/cas/HEAD/.claude/skills/cas-actuators/SKILL.md","title":"cas-actuators","category":"anthropic-skill","format":"markdown","content":"---\nname: cas-actuators\ndescription: \"Review and change CAS actuator endpoints: the security filter chain, access levels and IP access, REST actuator endpoints, exposure vs access, unmasked responses, request-path regexes, auditing and the actuator scenarios. Use when touching actuator endpoints or management security.\"\n---\n\n# Actuator endpoints\n\n## Actuator endpoints review discipline\n\n- There are ~63 endpoints. Inventory them with\n  `grep -rl --include=*.java -E \"@(RestControllerEndpoint|ControllerEndpoint|Endpoint|WebEndpoint)\\(id\"`;\n  `support/cas-server-support-reports-core` owns about a third, the rest are scattered across\n  feature modules. Review them as one attack surface, not one module at a time.\n- The whole security boundary is a single `SecurityFilterChain` built by\n  `CasWebSecurityConfigurerAdapter.configureHttpSecurity`. Rules are added in order --\n  ignored patterns (permitAll) -> each `CasWebSecurityConfigurer.configure` -> per-endpoint\n  `cas.monitor.endpoints.endpoint.*` -> `configureEndpointAccessToDenyUndefined` -> static\n  resources -> form login -- and Spring Security takes the first match, so anything a\n  `getIgnoredEndpoints` contributes wins over every endpoint rule that follows.\n- `prepareProtocolEndpoint` emits `/<ep>**` as well as `/<ep>/**`. The first form is a\n  within-segment wildcard, so an ignored endpoint also permits sibling paths sharing its\n  prefix. Check that before adding an entry.\n- That chain disables security headers and logout globally, and CSRF everywhere except the paths that configurers\n  declare through `getCsrfProtectedEndpoints()` (CIBA verification, WebAuthn). It is the same chain that serves the\n  actuator. Any browser-reachable session credential (`cas.monitor.endpoints.form-login-enabled`) therefore leaves\n  mutating actuator operations unprotected against cross-site requests.\n- `ActuatorEndpointProperties.access` defaults to `DENY` and the `endpoint` map always\n  carries a synthetic `defaults` key. The adapter iterates the map and calls\n  `EndpointRequest.to(\"defaults\")`, which resolves to no path and silently registers nothing;\n  the real default comes from `configureEndpointAccessToDenyUndefined`. Do not read the loop\n  as the place defaults are enforced.\n- `configureEndpointAccessToDenyUndefined` reads `management.endpoint.<rootPath>.access` with\n  a raw `Environment.getProperty` call and maps `UNRESTRICTED`/`READ_ONLY` to `permitAll`.\n  Relaxed spellings of that key are not seen (fail-closed), but the canonical spelling turns a\n  Boot-level exposure decision into anonymous Spring Security access.\n- `IP_ADDRESS` access is evaluated by `IpAddressAuthorizationManager`, which reads\n  `cas.audit.engine.alternate-client-addr-header-name` (default `X-Forwarded-For`) straight off the\n  request. This is intentional and is NOT a CAS defect: the fronting proxy, load balancer and\n  external boot configuration are expected to set that header as a trusted header, or to blank it\n  out and let Tomcat's own valves resolve the client address. Configuring that correctly is left to\n  the deployer, so do not report header spoofing here as a finding.\n- `BaseCasRestActuatorEndpoint` endpoints are NOT Spring Boot operations. They are discovered by\n  CAS's own `RestActuatorEndpointDiscoverer`, mapped by `RestActuatorEndpointHandlerMapping`, and\n  their handlers are Spring MVC mappings rather than `@ReadOperation`/`@WriteOperation` methods.\n  Boot applies an access level by filtering operations, and these endpoints declare none, so\n  `management.endpoint.<id>.access=READ_ONLY` does not stop their `@PostMapping`, `@PutMapping`,\n  `@PatchMapping` or `@DeleteMapping` handlers the way it stops a Boot write operation. That is\n  the gap `RestActuatorEndpointHandlerMapping.isMappingPermittedByAccessLevel` closes: it resolves\n  the endpoint's access through the `EndpointAccessResolver` bean and applies the same rules Boot\n  applies to operations, registering nothing under `NONE` and only `GET`/`HEAD`/`OPTIONS` mappings\n  under `READ_ONLY`. Note that `configureEndpointAccessToDenyUndefined` maps `READ_ONLY` to\n  `permitAll`, which is why the write methods used to get through, and maps `NONE` to `denyAll`, so\n  a `NONE` endpoint is usually refused by security before the missing mapping matters.\n  `ActuatorEndpointAccessControlTests` covers all of it, with `releaseAttributes` beside\n  `registeredServices` as the Boot operation endpoint that has always behaved this way.\n- Before changing what an access level does to these endpoints, measure the blast radius rather\n  than reasoning from the annotation: every REST actuator endpoint declares\n  `defaultAccess = Access.NONE` and the web application ships `management.endpoints.access.default=NONE`,\n  which suggests widespread breakage, but in fact all 44 test classes and all 69 puppeteer scenarios\n  that call one already declare `management.endpoint.<id>.access` or `management.endpoints.access.default`\n  explicitly. Walk the test tree and `ci/tests/puppeteer/scenarios/*/script.json` and count.\n- Do not confuse that with exposure. A REST actuator endpoint left out of\n  `management.endpoints.web.exposure.include` answers `403`, not `200`: no rule is registered for\n  it in `configureEndpointAccessToDenyUndefined`, and Spring Security denies a request no matcher\n  claims. Verify the status a given combination of exposure and access actually produces before\n  describing any of it as reachable.\n- When `management.server.port` differs, `CasCoreWebManagementContextConfiguration` registers the\n  handler mappings in the child management context but no `SecurityFilterChain`. Verify what\n  actually secures that port before assuming the main chain applies.\n- Several endpoints are authentication primitives, not diagnostics: `casValidate`,\n  `samlValidate` and `samlPostProfileResponse` mint CAS assertions and signed SAML responses\n  for an arbitrary username, and the `password` parameter is optional -- with no password they\n  fall through to `PrincipalResolver` and succeed. Anything that widens actuator access widens\n  impersonation. `tokenAuth` only validates a JWT and answers the principal with the service's\n  id, name and service id; it no longer issues tokens, and must never return service properties.\n- These endpoints return configuration values and service definitions unmasked, unlike Boot's own\n  `/env` and `/configprops`. `casConfig` `/retrieve` returns raw property values and\n  `registeredServices` serializes `OAuthRegisteredService.clientSecrets` (the `value` field carries\n  no `@JsonIgnore`). Masking was proposed and rejected, so do not re-propose it without agreeing\n  the approach with the maintainer first. `casConfig` `/encrypt` and `/decrypt` are a deliberate\n  operator tool that Palantir exposes in its UI, not an oracle to close.\n- Before proposing masking on any actuator response, work out how the admin UI consumes it: grep\n  `support/cas-server-support-thymeleaf/src/main/resources/static/js/palantir-*.js`. Palantir's\n  service editor does a full `GET /registeredServices/{id}` -> edit -> `PUT` round-trip\n  (`palantir-services.js`), so masking any path it reads would write the mask back into the\n  registry, and `/export` has the same requirement; redacting there needs the save path to preserve\n  existing secrets first. `casConfig` `/retrieve` does not have that problem, because\n  `palantir-pac4j.js` reads only `name` and `propertySource` from it.\n- User-supplied strings are compiled into regular expressions on the request path:\n  `configurationMetadata/{term}` (then matched over every known property in a `parallelStream`)\n  and `casConfig` `/retrieve`. Treat these as ReDoS/CPU-exhaustion boundaries; actuator endpoints\n  are not covered by CAS's authentication throttling.\n- No actuator endpoint carries `@Audit`. Session destruction, service deletion, key rotation,\n  configuration mutation and token minting all happen without an Inspektr record. Check for this\n  before claiming an administrative action is traceable.\n- Puppeteer coverage: `actuator-endpoint-cors`, `actuator-endpoint-login-jdbc`,\n  `actuator-endpoint-management`, `actuator-endpoint-metrics`, `actuator-endpoint-reports`,\n  `actuator-endpoint-roles`, `actuator-form-login`, `actuator-form-login-ldap`,\n  `actuator-form-login-ldap-roles`, `actuator-form-login-ldap-userauthz`,\n  `configuration-refresh-endpoint`, `sso-sessions-endpoint`, `tomcat-forwarded-headers`\n  (the only `IP_ADDRESS` scenario). Every one of them asserts that an *authorized* caller gets\n  200; none asserts 401/403 for an unauthenticated one, and 158 scenarios run with\n  `management.endpoints.web.exposure.include=*`. A change to endpoint security therefore needs a\n  new negative scenario rather than a rerun.\n","frontmatter":{"name":"cas-actuators","description":"Review and change CAS actuator endpoints: the security filter chain, access levels and IP access, REST actuator endpoints, exposure vs access, unmasked responses, request-path regexes, auditing and the actuator scenarios. Use when touching actuator endpoints or management security."},"isInternal":false,"tokens":1996,"sizeBytes":8768},{"name":"SKILL.md","path":".claude/skills/cas-attribute-consent/SKILL.md","rawUrl":"https://raw.githubusercontent.com/apereo/cas/HEAD/.claude/skills/cas-attribute-consent/SKILL.md","title":"cas-attribute-consent","category":"anthropic-skill","format":"markdown","content":"---\nname: cas-attribute-consent\ndescription: \"Review and change attribute consent: ConsentDecision, the consent webflow gate, ConsentQueryResult, repositories and their lookups and deletes, decision decryption, dates and the consent scenarios. Use when touching support/cas-server-support-consent*.\"\n---\n\n# Attribute consent\n\n## Attribute consent review discipline\n\n- Start at `ConsentDecision`, not at a repository. `id` is a primitive `long` with\n  `@GeneratedValue`, so only JPA generates one. That single field is the key in four\n  backends: LDAP merges by it, Redis keys on `ConsentDecision:<principal>:<id>`, Mongo maps\n  it to `_id` by naming convention, and DynamoDB uses it as the table HASH key. It used to\n  stay `0` on every one of them, which collapsed each user (LDAP, Redis) or the whole\n  deployment (Mongo, DynamoDB) onto a single decision. `DefaultConsentDecisionBuilder.build`\n  now assigns a random positive id, JPA still replaces it with a generated one, and LDAP's\n  `mergeDecision` assigns one when `id <= 0`. Anything that creates a `ConsentDecision`\n  outside the builder -- the actuator's `/import`, say -- still arrives with id `0`.\n- `BaseConsentRepositoryTests` used to call `decision.setId(1/100/200)` before every store,\n  which is why the id defect never failed a test. `verifyMultipleDecisionsForPrincipal` now\n  stores builder-produced decisions for two users across two services and then deletes by id\n  and by principal, against every backend; `getOtherUser()` is overridden where the second\n  user must already exist (LDAP). The class holds a `@ResourceLock` because\n  `verifyDeleteRecordsForPrincipal` calls `deleteAll()` on the shared repository.\n- Consent is a gate in the login webflow, not a filter at attribute release.\n  `ConsentWebflowConfigurer` prepends `CheckConsentRequiredAction` to the login flow's\n  `generateServiceTicket` state; the only other consumer is\n  `SamlIdPConsentSingleSignOnParticipationStrategy`. Nothing in\n  `RegisteredServiceAttributeReleasePolicy` reads a stored decision, so the consented set\n  never constrains what is released, and any path that issues without the login flow --\n  REST protocol tickets, proxy tickets, OIDC refresh/userinfo, the OpenID4VCI\n  pre-authorized code grant -- releases attributes with no consent check at all.\n- Follow `ConsentQueryResult`. It carries the decision, the service and now the resolved\n  consentable attributes, and it used to be thrown away: `ConsentActivationStrategy` returned\n  a bare boolean and `CheckConsentRequiredAction` reduced it to an event, so\n  `prepareConsentForRequestContext` repeated the full `getConsentableAttributes` evaluation\n  (person directory included) and the repository read in the same request. The strategy now\n  returns the result and the action hands it to `prepareConsentForRequestContext`, which\n  resolves nothing when the result carries attributes -- a result built by a custom strategy\n  carries none and still falls back to resolving. What remains per consent interaction is one\n  evaluation and read for the prompt, one of each inside `storeConsentDecision` on confirm,\n  and one more from the `generateServiceTicketAfterConsent` clone that re-runs the check;\n  `getRegisteredServiceForConsent`, and with it the access strategy, still runs twice per\n  prompt. Count these by request before claiming a number.\n- `DefaultConsentEngine.executeRepositoryOperation` calls `toConsentRepository(tenant)` on\n  every operation, and `TenantJdbcConsentRepositoryBuilder` builds a DataSource plus a\n  Hibernate `EntityManagerFactory` inside it, then destroys them. Check the multitenant\n  path before judging consent performance; the single-tenant path is not representative.\n- Repository lookups must be checked for operator and pattern correctness, not just for\n  the right column names. `DynamoDbConsentFacilitator` matched SERVICE and ID with\n  `ComparisonOperator.GE` (correct for the date ranges other CAS DynamoDB facilitators\n  use it for, wrong for an equality lookup); it now uses `EQ`, but every read is still a\n  full `scanPaginator` because `id` is the table's only key. `RedisConsentRepository`\n  interpolated the raw principal into a SCAN glob; it now escapes glob characters and keeps\n  only keys whose remainder is a numeric id, because the `:` delimiter alone lets principal\n  `alice` reach the decisions of `alice:x`. Deleting one decision there is a `DEL` of the\n  exact key rather than a scan.\n- Deletes deserve the same reading as reads. `JpaConsentRepository.deleteConsentDecisions`\n  used `getSingleResult()` and so silently deleted nothing for any user with more than one\n  decision; it is a bulk JPQL delete now. `ChainingConsentRepository` -- which is what the\n  `consentRepository` bean always is, wrapping every configured store -- stored to every\n  repository but deleted with `anyMatch`, stopping at the first success; it now asks every\n  repository. `RestfulConsentRepository` sent `id` where the documented contract and the\n  test's own mock server say `decisionId`, so a conforming server read a single revocation\n  as \"revoke everything\". Revocation failing quietly is worse than consent failing loudly.\n- A decision that will not decipher used to be fatal: `getConsentableAttributesFrom` turned any\n  failure into `IllegalArgumentException`, which propagated out of `isConsentRequiredFor` and out\n  of the login flow, so key rotation or a node without the shared `cas.consent.core.crypto` keys\n  locked the user out. It now logs and returns no attributes, which reads as a mismatch and\n  re-prompts; the new decision is written back with the current keys. Weigh every failure on this\n  path the same way -- the login flow has no recovery for an exception thrown from the consent\n  check, so the question is always whether re-prompting is safe, not whether the error is correct.\n- `ConfirmConsentAction` parsed `option`, `reminder` and `reminderTimeUnit` straight off the request\n  with `Integer.parseInt` / `Long.parseLong` / `ChronoUnit.valueOf` and persisted them unvalidated, so\n  a missing parameter was a 500 on the confirm POST and `ChronoUnit.FOREVER` or `ERAS` parsed fine and\n  then threw from `createdDate.plus(...)` on every later login. Each value now falls back on the\n  configured default, and the pair is rejected unless it can actually be applied to a date -- checked by\n  applying it, since `LocalDateTime.isSupported` accepts `ERAS` and only the ISO era range rejects it.\n  `DefaultConsentEngine.calculateReminderExpirationDate` is the second half of that: a stored reminder\n  that cannot produce a date means consent is required, never an exception, which is what protects\n  decisions that were written before this validation existed or through the actuator's import.\n- `ConsentDecision.createdDate` is a zone-less `LocalDateTime` that one node writes and another compares\n  against its own clock, so it is produced with `ZoneOffset.UTC`, not `ZoneId.systemDefault()`. Before that,\n  the backends disagreed with each other: JPA, Redis, LDAP, DynamoDB and REST keep the wall-clock value\n  verbatim, so a node in another zone read a decision as hours older or newer, while MongoDB was accidentally\n  correct because Spring Data converts `LocalDateTime` through the system zone in both directions and so\n  normalizes to the instant. The reminder units offered in the consent view go down to seconds, so that skew\n  is not academic. `OneTimeToken.issuedDateTime` and the Google Authenticator token repositories still carry\n  the same pattern with second-scale windows.\n- `cas.consent.core.active` defaults to `true`, so adding the module turns consent on for\n  every service that releases attributes. Weigh findings about the consent path as if it\n  is always on.\n- Wallet/OID4VCI integration is the real gap, not a defect in the consent modules:\n  `BaseOidcVerifiableCredentialEncoder` reads `principal.getAttributes()` directly against\n  the issuer's configured claim list, so a credential handed to a wallet carries no\n  consent record, no per-claim choice, and no revocation. Selective disclosure exists in\n  the SD-JWT encoder but the holder never chooses what is disclosed at issuance time.\n- Puppeteer coverage: `attribute-consent` (in-memory repository, one user, one service),\n  `multitenancy-consent-jdbc`, `saml2-idp-login-consent`, `saml2-idp-login-consent-with-sso`.\n  No scenario exercises LDAP, Redis, Mongo, DynamoDB or REST consent, and none stores two\n  decisions for the same user, which is exactly the shape every storage defect above needs.\n","frontmatter":{"name":"cas-attribute-consent","description":"Review and change attribute consent: ConsentDecision, the consent webflow gate, ConsentQueryResult, repositories and their lookups and deletes, decision decryption, dates and the consent scenarios. Use when touching support/cas-server-support-consent*."},"isInternal":false,"tokens":1972,"sizeBytes":8554},{"name":"SKILL.md","path":".claude/skills/cas-build-ci/SKILL.md","rawUrl":"https://raw.githubusercontent.com/apereo/cas/HEAD/.claude/skills/cas-build-ci/SKILL.md","title":"cas-build-ci","category":"anthropic-skill","format":"markdown","content":"---\nname: cas-build-ci\ndescription: \"Gradle build settings (configuration cache, Isolated Projects, build cache, test-category discovery) and the GitHub Actions workflows (tests.yml coverage job, setup-gradle caching, daemon reuse, CodeQL). Use when changing build logic under buildSrc or gradle/, the root Gradle settings, or anything under .github/workflows or ci/.\"\n---\n\n# CAS build and CI workflows\n\n## Workflows that matter here\n\n- Gradle 9.8.0 enables build caching, the configuration cache, parallel execution and Isolated Projects here.\n  Isolated Projects configures every project on a cache miss and ignores configuration on demand. Compare representative\n  scoped commands before changing either setting. User-home `gradle.properties` overrides the repository's JVM settings.\n- Test category discovery runs inside `TestCategoryTagsValueSource`: only its sorted tag list is a configuration input.\n  Implementation edits retain the cached graph; adding/removing categories invalidates it. Do not move source reads back\n  into the convention script or restore the old `.gradle/cas-test-tags` fingerprint cache.\n- An IDE `JetGradlePlugin` error about `setExcludedTaskNames` comes from the IDE integration, not category discovery.\n  Use `--no-isolated-projects` for the affected IDE invocation until that integration supports Isolated Projects; keep\n  configuration caching enabled and do not disable Isolated Projects globally to hide an IDE-only violation.\n\n## Practical boundaries\n\n- Unit-test matrix jobs (`tests.yml`) record JaCoCo data only (`testcas.sh --with-coverage` only turns on the agent) and upload the `.exec` files; the single `coverage` job builds `jacocoRootReport` and does every Sonar/Codecov/Coveralls/Codacy upload. Do not move `jacocoRootReport` or `sonar` back into the matrix: the root report compiles all ~430 projects and Sonar re-analyses the whole code base, which is what made each category job 17 minutes instead of 7.\n- Every `gradle/actions/setup-gradle` step sets `gradle-home-cache-excludes: caches/build-cache-1` and\n  `cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}`. Preserve both: the local build cache duplicates Develocity\n  outputs, and the encryption key enables saving/restoring the project configuration cache. Fork PRs without this secret\n  can still build, but cannot restore the encrypted configuration cache. Do not layer `setup-java` or GraalVM Gradle\n  caching over `setup-gradle` in the same job.\n- CI permits Gradle daemon reuse within a job; `ci/init-build.sh` must not append a disabling user-home property.\n  Release, documentation and scenario scripts preserve daemon reuse across their Gradle invocations. CodeQL keeps\n  `--no-daemon` for compiler tracing. Test categories and Puppeteer scenarios are fetched once per discovery\n  job; print the captured result and split scenario JSON with `jq`, preserving the existing matrix boundaries.\n- The CodeQL job (`analysis.yml`) applies `--no-build-cache --rerun-tasks` after its shared build options so traced\n  compilation actually runs. The current `settings.gradle` has no CodeQL-specific `JavaCompile` cache guard. Preserve\n  these workflow flags: CodeQL only extracts compilations it observes, so restored or up-to-date outputs empty the scan.\n","frontmatter":{"name":"cas-build-ci","description":"Gradle build settings (configuration cache, Isolated Projects, build cache, test-category discovery) and the GitHub Actions workflows (tests.yml coverage job, setup-gradle caching, daemon reuse, CodeQL). Use when changing build logic under buildSrc or gradle/, the root Gradle settings, or anything under .github/workflows or ci/."},"isInternal":false,"tokens":720,"sizeBytes":3270},{"name":"SKILL.md","path":".claude/skills/cas-docs-site/SKILL.md","rawUrl":"https://raw.githubusercontent.com/apereo/cas/HEAD/.claude/skills/cas-docs-site/SKILL.md","title":"cas-docs-site","category":"anthropic-skill","format":"markdown","content":"---\nname: cas-docs-site\ndescription: \"Edit the CAS documentation site (docs/cas-server-documentation, Jekyll plugins, site.js/site.css, ci/docs/publish.sh, the docs data generator) and release notes: page titles and descriptions, setting links, settings rules, release-notes front matter and tags, Quick Start wizard, sidebar, mobile layout. Use when editing any documentation page or release_notes/RC*.md.\"\n---\n\n# CAS documentation site\n\n## Practical boundaries\n\n- Documentation pages on gh-pages are pre-rendered per version, but they load the stylesheet and script from the shared site root, and every publish overwrites those root files. The current design ships as `stylesheets/site.css`, `stylesheets/site-print.css` and `javascripts/site.js`; the root `stylesheet.css`, `print.css` and `main.js` belong to the released versions' old markup. A redesign that changes page markup must use new asset names, never replace the ones older versions link.\n- The documentation data generator (`docs/cas-server-documentation-processor`) runs as `java @build/casdocsgen.args ...`, an argument file written by its `docsGeneratorArguments` task from `sourceSets.main.runtimeClasspath`; `publish.sh` no longer builds the ~1 GB `casdocsgen.jar` boot jar. Its Gradle run adds `-DskipErrorProneCompiler=true` (override with `DOCS_GENERATOR_GRADLE_OPTIONS`, an empty value restores Error Prone). It still needs every CAS module compiled: actuators, feature toggles and shell commands are found by ClassGraph scans of the runtime classpath, and third-party settings come from the dependencies' metadata. Those lookups go through `CasDocumentationClassIndex`, one ClassGraph scan of `org` shared by the exporters; add new class lookups there rather than calling `ReflectionUtils`, which scans the whole classpath on every call.\n- `ci/docs/publish.sh` exit codes carry meaning for the workflow retry (`retry_on_exit_code: 1`): 1 is a failure worth another attempt, 3 is broken internal links/images/scripts (fails at once, nothing is published), 4 is broken external links only (the site is published first, then the job fails). Keep new failure paths on 1 unless a rerun cannot help. External links are checked only on the weekly schedule or when asked (`--proof-external`, the `proofReadExternal` input or `vars.DOCS_PROOF_EXTERNAL`), with successful results cached for 7 days in `build/htmlproofer`.\n- Actuator endpoint blocks (`_includes_site/actuators.html`) take their operations from the `cas_actuator_operations` filter in `_plugins/cas_actuators.rb` (sorted, parameters normalized, curl built there), and settings snippets from `cas_actuator_enable_snippet` / `cas_actuator_security_snippet` via `cas-actuator-snippet.html` in all three formats. Per operation, render only what differs (parameters, response, example); setup, security, settings and troubleshooting are rendered once per include in the shared tabs. The operations table is excluded from `responsiveTables()`.\n- Settings search: `ci/docs/index.js` writes `assets/data/<version>/index.json` as `{generated, docs}` (no lunr index; each doc carries name, short type, description, default, kind required/optional/thirdparty, module, duration, deprecation). `site.js` searches it directly (`searchCasSettings`, shared by the Configuration Properties page and the Shift-Shift palette); the page state lives in the URL (`?q=&exact=1&scope=name&kind=cas|thirdparty&deprecated=hide`). The layout exposes `data-docs-base`, `data-docs-version` and `data-docs-build` on `<body>` for these URLs.\n- Feature toggles (`Configuration-Feature-Toggles.md`) render through `_includes_site/cas-feature-toggles.html` from the `cas_feature_catalog` filter in `_plugins/cas_features.rb`, which derives feature and module from the `CasFeatureModule.<Feature>[.<module>].enabled` property, groups features by area and links docs only when the target page exists for that version. Add title overrides, groups or docs links there, not in the include. The environment variable form maps both `.` and `-` to `_` (`CasFeatureEnabledCondition` reads through `Environment.getProperty`), unlike the relaxed-binding form used for regular settings.\n- Module dependency tabs (`_includes_site/casmodule.html`, included through `include_cached`) are plain Bootstrap tabs: no inline script, the default tab and pane are chosen together in Liquid, and pane ids use the full coordinates plus the variant options. Do not add per-include scripts or truncated ids; either one lets the selected tab and the visible pane drift apart.\n- Local serving (`publish.sh --serve true`) keeps the configured `baseurl: /cas`, so the site answers at `http://localhost:4000/cas/<version>/` exactly like production. `variables.html` always sets `basePath` to `/cas` and `site.js` has no localhost path branches; do not reintroduce `--baseurl \"\"` or localhost-specific paths.\n- Release notes (`release_notes/RC<n>.md`) carry a `release:` front matter block (summary, facts, upgrade notes, highlights, optional spotlight over a front matter image list) and open with `{% include release-digest.html %}` and end with `{% include release-footer.html %}`. Tag New & Noteworthy topics with a block IAL on the line after the heading and Other Stuff bullets with a list item IAL (`- {: .fixed} ...`): types are `new`, `changed`, `fixed`, `action`, `removed`; add `data-area=\"...\"` only when the first link does not identify the area. The area must be a key of `AREAS` in `_plugins/cas_release_notes.rb` (SSO cookies and sessions are `tickets`; there is no `sso`). `_plugins/cas_release_notes.rb` normalizes the tags, groups Other Stuff by area and resolves `section:` names to kramdown GFM heading ids before rendering; a `section:` that names no heading logs a warning. Upgrade notes may point at an Other Stuff area group by its label. `release_notes/RC.md` (front matter `release_aggregate:`) is generated from every RC page in the same folder: topics with the same title are merged and show each release candidate's notes, identical Other Stuff items are listed once with all their release candidates, and the `<!-- release-changes -->` marker is where the generated changes go. Never edit changes into that page by hand.\n- Section heading icons come from `CAS_SECTION_ICONS` in `site.js` and apply only to direct `h2` children of the article whose id is in that map; add an entry there rather than icons in markdown.\n- In the development docs, `site.js` wraps every table inside `#cas-docs-container` in a framed `.table-scroll` card, so do not use `<table>` for layout inside components (buttons, headers, badges); use flex markup instead.\n- Documentation property blocks get their settings from the `cas_properties` / `cas_third_party_properties` filters in `docs/cas-server-documentation/_plugins/cas_properties.rb`; do not loop over `site.data` in Liquid for this, it scans ~14k entries per block. Liquid `assign` inside an include writes to the page scope, so a `casproperties` include nested inside another block's panels must pass `topics=\"false\"` (and usually `intro=\"false\"`) or it resets the outer block's state. Their output is wrapped in `{::nomarkdown}`, so include them at the start of a line in markdown or pass the capture through `markdownify` (with `|`, not `||`). Catalog descriptions carry raw `<`/`>` (for example `management.endpoint.<id>.access`); the plugin escapes every tag outside `INLINE_TAGS` into `descriptionHtml` / `summaryText`, so print those fields and never unescape descriptions in Liquid: one stray tag inside the actuator modal makes kramdown drop the closing tags and everything after it on the page.\n- Documentation page titles must be unique: use `title: CAS - <page H1>`, and give overview pages a `description:`\n  front matter entry (under 160 characters). Pages without one get their meta description from the first paragraph in\n  `_layouts/default.html`, which skips dependency boilerplate, short or colon-ended lead-ins and \"see this guide\" lines.\n- The Versions menu lists `documentation_versions` from `docs/cas-server-documentation/_config.yml` (development plus\n  maintained releases); update it together with the EOL schedule in `developer/Maintenance-Policy.md`.\n- Third-party CSS and JS in `_layouts/default.html` are pinned to exact versions with `integrity` (sha384) and\n  `crossorigin=\"anonymous\"`. Recompute the hash whenever a version changes; Google Fonts and the gtag script cannot carry one.\n  Analytics is the GA4 tag `G-W3M8JXP7GP`, loaded only on `apereo.github.io`.\n- Do not dim muted text with `opacity`: `--docs-muted` on its own meets 4.5:1 in both themes, and opacity drops it below.\n  `--docs-amber` is darker in light mode for the same reason.\n- In `casmodule.html`, only the tab links sit inside `role=\"tablist\"`; the Resources dropdown is a sibling `<button>`.\n- Mobile layout: content must never run past the 1.25rem gutter. A flex or grid child that holds long inline `code` needs\n  `min-width: 0`, and the code needs `overflow-wrap: anywhere` (`break-word` does not lower the min-content width, so the\n  item still overflows). Bootstrap `.row` blocks in content lose their negative margins below 760px. To check, load pages in\n  375px iframes and look for elements whose right edge passes `#cas-docs-container` outside a scrolling ancestor.\n- Release notes pages carry `docs-release-notes` on `<body>` (set in `_layouts/default.html` from the page URL); their prose,\n  footer included, is justified and hyphenated, while stats, facts, the release switcher and inline code are left alone.\n- Docs sidebar: `sidebar.md` stays a plain nested list; `buildSidebarTree()` in `site.js` turns it into the grouped tree. A\n  section's group comes from `CAS_SIDEBAR_GROUPS` (by the section's `#anchor`), so add a new top-level section there too; one\n  left out lands in the last group. A parent whose first child is an `Overview` link becomes that link (the row is dropped);\n  other parents only toggle. Only the current path opens, siblings close as another opens, open rows are sticky at\n  `--sidebar-filter-h + depth × --sidebar-row-h`, and `.docs-sidebar` has no top padding so the filter can pin at the very top.\n- Settings in docs (required, standing rule from the maintainer): never refer to a configuration setting by name in\n  documentation pages, neither inline nor in fenced `properties` blocks. Describe the feature, say that it is available,\n  and say that it is enabled, disabled or tuned through CAS settings; the page's `casproperties` include lists them. The\n  only exception is release notes, which may name CAS or other settings.\n- Setting links in release notes (required): every setting named in release notes must be marked so readers can look\n  it up in place. Inline, write `` `cas.server.name`{: .cas-setting} ``\n  (an assignment such as `` `cas.tgc.secure=false`{: .cas-setting} `` works too; the value is ignored for the lookup). For a\n  fenced `properties` block of settings, put `{: .cas-settings-linked}` on the line right after the closing fence. Mark only\n  real CAS or Spring setting names, not prefixes used as headings, removed settings, JSON fields or registered-service\n  properties. `initializeCasSettingLinks()` in `site.js` turns these into buttons that call `openCasSettingsPalette(name)`:\n  an exact match opens the setting's details inside the Shift-Shift palette, a partial name shows the matches; the page\n  never changes. Linked blocks are rebuilt line by line from their text (key, separator, value), because Rouge's\n  properties lexer splits keys with `[0]` into separate tokens. `casSettingsFlat()` drops anything in brackets and any\n  purely numeric name segment, so `[0]`, `[]`, the catalog's map notation `.[key]`, `.0.` and the environment variable form\n  `_0_` all match the same setting. No catalog name has a numeric segment of its own; recheck that if one is ever added. `planning/Quick-Start.md` is the reference page.\n- Quick Start wizard (`planning/Quick-Start.md`, `javascripts/quick-start.js`): the page needs exactly eight `## N. ` steps;\n  anything else on the page is an `###` inside one of them, or it shows on every step. `data-quick-feature` takes a\n  space-separated list and shows the section when any listed feature is selected. Each checkbox's `data-module` must match\n  the `casmodule` include in its recipe (the fieldset's `data-modules` holds the always-included ones): step 2's\n  Initializr command and step 6's `dependencies` block are built from them, and step 6's `cas.properties` from every\n  visible `.cas-settings-linked` block in steps 3–5, in page order, under the nearest heading. Generated blocks are\n  plain fenced blocks inside `[data-quick-output]`; the script rewrites their text and gutter and calls\n  `linkCasSettingsBlock()` from `site.js` again. The selection is page state: popstate restores the step only, and a link\n  into a recipe selects its first feature with a notice. Checkboxes may declare `data-requires` and `data-excludes`\n  (space-separated feature values; Interrupt requires Groovy, Redis and Hazelcast exclude each other, declared on both\n  sides): checking selects requirements and clears conflicts, clearing a feature clears its dependents, Check All adds\n  only what fits, and a selection from the address is normalized with earlier features winning. Test the wizard in\n  headless Chromium against a rendered copy (Back/Forward, deep links, rules, Check All output) after changes.\n- Every documentation page needs an opening paragraph right after the H1 that the layout can use as its description:\n  at least 40 characters, not ending in a colon, and not a \"see this guide\" or dependency lead-in.\n- On phones (`max-width: 760px`) the sticky header is offset by `--docs-masthead-height` so only the navigation bar\n  stays visible, and `site.js` sets `--docs-header-height` to that bar alone; keep both in step when changing the masthead.\n- The docs load two web fonts only, Bricolage Grotesque (headings) and DM Sans (everything else); `--font-mono` is the system\n  monospace stack. Do not add font families or hotlink images from third-party hosts; keep images under `images/`.\n- Large topics are split into one page per concern with a sidebar submenu (see `authorization/Heimdall-Authorization-*.md`\n  and `multitenancy/Multitenancy-*.md`); when moving a section, update cross-page anchors and links in release notes.\n\n## FIDO2 WebAuthn / passkeys review discipline\n\n- JSON examples pasted into the documentation are pretty-printed (one member per line, two-space indent), never minified.\n","frontmatter":{"name":"cas-docs-site","description":"Edit the CAS documentation site (docs/cas-server-documentation, Jekyll plugins, site.js/site.css, ci/docs/publish.sh, the docs data generator) and release notes: page titles and descriptions, setting links, settings rules, release-notes front matter and tags, Quick Start wizard, sidebar, mobile layout. Use when editing any documentation page or release_notes/RC*.md."},"isInternal":false,"tokens":3378,"sizeBytes":14566},{"name":"SKILL.md","path":".claude/skills/cas-google-authenticator/SKILL.md","rawUrl":"https://raw.githubusercontent.com/apereo/cas/HEAD/.claude/skills/cas-google-authenticator/SKILL.md","title":"cas-google-authenticator","category":"anthropic-skill","format":"markdown","content":"---\nname: cas-google-authenticator\ndescription: \"Review and change Google Authenticator (GAuth) MFA credential repositories across backends (JPA, Redis, Mongo, DynamoDB, LDAP, JSON, REST, in-memory): owner-scoped deletes, encoding, counts, verification flags, the Redis index, crypto. Use when touching support/cas-server-support-gauth*.\"\n---\n\n# Google Authenticator MFA repositories\n\n## Google Authenticator credential repositories (all backends)\n\n- Review every backend against the same contract, with crypto on: `cas.authn.mfa.gauth.crypto.enabled` defaults to `true`, but every puppeteer scenario and the repository tests turn it off, so undecoded returns and double encryption never show up in CI.\n- A device delete must be scoped to its owner. WebAuthn, YubiKey and Duo device managers delete by username and id; `OneTimeTokenCredentialDeviceManager` and `GoogleAuthenticatorDeleteAccountAction` delete by id alone, and ids are guessable (JPA sequence; `currentTimeMillis` before 42bcde1).\n- `BaseOneTimeTokenCredentialRepository.encode()` mutates its argument. Never pass an object you will keep or save again; JPA `get(id)` and InMemory `get(id)` return stored, undecoded instances, and updating them double-encrypts.\n- `count()` means devices in Redis, JPA, Mongo and DynamoDB, but users in InMemory, JSON and LDAP; check which a caller assumes.\n- A repository read that fails must not look like \"no devices\": `OneTimeTokenAccountCheckRegistrationAction` sends empty or null results to enrollment (REST returns null, JSON returns empty on errors).\n- When testing deletes or updates, store devices for two users and two devices per user, and assert the others survive; single-user tests hid the DynamoDB `GE` filter and the LDAP update bug.\n- To make a range-filter bug visible in a DynamoDB delete test, pick keys that a wrong comparison would sweep up: the kept user's name sorts after the deleted one (\"z…\" vs \"a…\"), and the kept device ids are larger than the deleted id. Random UUIDs alone pass by luck half the time.\n- DynamoDB tables keyed by `id` should delete with `DeleteItem` on that key, not a scan followed by deletes.\n- Webflow actions and device managers that take a device id from the request must load it with the owner-scoped `get(principal.getId(), id)` and act only on what that returns. Unscoped `get(id)` / `delete(id)` are for the actuator and internal paths.\n- Every repository read returns a decoded copy (JPA detaches before decoding); `update()` must persist `properties`, which the verification flags of the delete and confirm actions live in.\n- The delete/confirm verification flags are stored on the account as `<flag>:<epoch millis>` and expire (`GoogleAuthenticatorAccountVerificationUtils`). They cannot live in flow or conversation scope: the login webflow keeps its execution state client-side, and the AJAX check and the form submit carry different execution keys.\n- `CasReentrantLock.tryLock(supplier)` returns null when the lock is not acquired; never let that null reach a caller as \"no accounts\".\n- `BaseOneTimeTokenCredentialRepository.encode()` returns an encoded copy and leaves its argument alone; store and return the copy, and read normalized values (lowercased username) from it, not from the argument.\n- Per-tenant ciphers must be built once and reused: a cipher built from settings with blank keys generates its own keys, so a new instance per call cannot decode what the previous one encoded. `BaseOneTimeTokenCredentialRepository` caches them by tenant and crypto settings.\n- WebAuthn FIDO metadata: `FidoMetadataDownloader.refreshBlob()` always downloads; `loadCachedBlob()` uses the cache until the BLOB's `nextUpdate`. CAS refreshes at startup and falls back to the cache on `IOException` (the FIDO service rate-limits with HTTP 429). `CompositeAttestationTrustSourceTests` downloads from the real service; a 429 there with no cache from an earlier run is an outage, not a regression.\n- Active Directory keeps a single value of `description` on user objects. The LDAP GAuth repository therefore writes all of an entry's accounts as one JSON array value; writing one value per account loses all but one device on AD.\n- A webflow action that validates a GAuth token and then updates the device must read the device again after validation: the validator updates its own copy (used scratch code, last-used time), and updating an older copy undoes that.\n\n## Google Authenticator Redis repository\n\n- Accounts live twice: the record at `CAS_TOKEN_ACCOUNT:<id>` and a copy as a member of the set `CAS_TOKEN_PRINCIPAL:<username>`. `get(username)`, `count(username)` and OTP validation read the set, not the record, so a delete that removes only the record leaves a device that is still listed and still validates.\n- Reads must not repair the index. A read that removes set members whose record \"does not exist\" acts on whatever the read saw; with `read-from` sending reads to replicas, and the record and the set on different cluster slots, a lagging replica makes a live device look orphaned and the removal on the primary is permanent. Repair belongs on the explicit `delete(id)`: when the record is missing the owner is unknown, so it scans `CAS_TOKEN_PRINCIPAL:*` in batches and removes members with that id.\n- Remove set members by the raw bytes Redis returned (`executePipelined(..., RedisSerializer.byteArray())` then `sRem`), not by re-serializing a deserialized account; the value serializer is LZ4 over JDK serialization, and a removal only matches if the bytes are identical.\n- Spring Data Redis pipelining works on Lettuce cluster connections (`LettuceClusterConnection` does not override `openPipeline`) and key `SCAN` spans all nodes, so pipelines and scans here are cluster-safe.\n","frontmatter":{"name":"cas-google-authenticator","description":"Review and change Google Authenticator (GAuth) MFA credential repositories across backends (JPA, Redis, Mongo, DynamoDB, LDAP, JSON, REST, in-memory): owner-scoped deletes, encoding, counts, verification flags, the Redis index, crypto. Use when touching support/cas-server-support-gauth*."},"isInternal":false,"tokens":1269,"sizeBytes":5752},{"name":"SKILL.md","path":".claude/skills/cas-groovy-scripting/SKILL.md","rawUrl":"https://raw.githubusercontent.com/apereo/cas/HEAD/.claude/skills/cas-groovy-scripting/SKILL.md","title":"cas-groovy-scripting","category":"anthropic-skill","format":"markdown","content":"---\nname: cas-groovy-scripting\ndescription: \"Review and change Groovy scripting support (core/cas-server-core-scripting): ScriptResourceCacheManager, CompiledGroovyScript, inline vs external scripts, compiling off the request path, script security. Use when touching Groovy scripts or any module that runs them.\"\n---\n\n# Groovy and scripting\n\n## Groovy and scripting\n\n- Start at `core/cas-server-core-scripting`; roughly fifty modules delegate to the same five classes, so\n  changes there are changes to authentication, attribute release, MFA, AUP, OIDC, SAML and themes at once.\n- `ScriptResourceCacheManager` is a singleton whose `close()` clears the entire cache — it must never be\n  used in a try-with-resources block.\n- `CompiledGroovyScript.of` decides how a script runs: a stateless script (only final fields holding immutable\n  values) gets a fresh instance per call and runs in parallel; a stateful one keeps a shared instance serialized\n  on a blocking lock, on purpose, so its fields stay consistent. Do not report that lock as a bottleneck. Review every script-backed\n  decision for what a `null` result means and prefer fail-closed defaults at security boundaries — MFA\n  triggers and AUP read `null` as \"skip\". Never guard script execution with a timed `tryLock`; a timeout\n  that returns `null` is indistinguishable from a script that declined.\n- Do not compile scripts on a request path (`fromScript`/`fromResource` per call). Go through\n  `resolveScriptableResource`, or hold the compiled script in a transient field, and remember `fromResource`\n  also starts a file-watcher thread that nothing closes. The cache manager comes from\n  `ApplicationContextProvider` and is absent outside Spring, so always fall back to the factory instead of\n  throwing, or you will break components and tests that are constructed directly.\n- Inline (`groovy { ... }`) and external (`file:`/`classpath:`) scripts behave differently: only external\n  scripts honour `failOnError` and support reload, and only inline scripts support bindings (the\n  `setBinding` interface default is a no-op). Inline scripts swallow `GroovyRuntimeException`. Test both\n  forms, and cover bindings with concurrent and pooled-thread tests — `GroovyShellScriptTests.BindingTests`\n  is the pattern.\n- Treat compiling a Groovy script as running it: AST transformations execute inside the compiler. Script\n  text that comes from anywhere other than deployment configuration must be parsed\n  (`ScriptingUtils.validateGroovyScript`), never compiled.\n- Puppeteer coverage lives in the 18 `ci/tests/puppeteer/scenarios/*groovy*` scenarios\n  (`service-access-strategy-groovy`, `surrogate-login-groovy`, `mfa-provider-selection-trigger-groovy`,\n  `interrupt-afterauthn-groovy`, `webflow-groovy-action`, …). None of them exercise concurrency, script\n  errors, or cache expiry, so a change in this area needs new coverage rather than a rerun.\n","frontmatter":{"name":"cas-groovy-scripting","description":"Review and change Groovy scripting support (core/cas-server-core-scripting): ScriptResourceCacheManager, CompiledGroovyScript, inline vs external scripts, compiling off the request path, script security. Use when touching Groovy scripts or any module that runs them."},"isInternal":false,"tokens":654,"sizeBytes":2913},{"name":"SKILL.md","path":".claude/skills/cas-handoff/SKILL.md","rawUrl":"https://raw.githubusercontent.com/apereo/cas/HEAD/.claude/skills/cas-handoff/SKILL.md","title":"cas-handoff","category":"anthropic-skill","format":"markdown","content":"---\nname: cas-handoff\ndescription: \"Finish and hand off a change in the CAS repository: Checkstyle on changed Java files, no stale .git/index.lock, release-notes and puppeteer cross-checks, and the ready-to-use commit message. Use at the end of every task that changed files in cas-server, before reporting the work as done.\"\n---\n\n# Handing off CAS work\n\nRun these steps, in order, before reporting the work as done. The rules behind them follow.\n\n1. Compile what changed, main and test sources, with Error Prone, each changed module on its own class path so a\n   missing test dependency shows (see Conventions in AGENTS.md, the `cas-testing` skill and, in a sandbox without\n   Gradle, the `cas-sandbox-verification` skill).\n2. Run Checkstyle on every changed Java file (rule below), and fix overloads, imports and file length it reports.\n3. Run `git --no-optional-locks diff --check`.\n4. Cross-check the puppeteer scenarios that cover the change and update them (`cas-puppeteer-scenarios` skill).\n5. Add the release-notes entry when the work calls for one (security notes: see \"Test, concurrency, and hand-off\n   expectations\" in AGENTS.md; release-notes format: `cas-docs-site` skill).\n6. Record new, durable lessons in AGENTS.md or in the matching skill under `.claude/skills/`.\n7. Check `ls .git/*.lock` and clear what is left.\n8. End with the commit message (rule below). Say clearly what was not run.\n\n## Test, concurrency, and hand-off expectations\n\n- When the work is done, end the hand-off with a ready-to-use commit message covering only the uncommitted changes\n  (`git --no-optional-locks status`): a conventional subject (`fix:`, `feat:`, ... as in `git log`) and a short body.\n  The maintainer commits; do not commit yourself.\n\n## Environment limits\n\n- Leave no `.git/index.lock` behind. `git status` and `git diff` take that lock to refresh the index, and in a remote environment that cannot delete files the lock survives the command and blocks every subsequent git operation the maintainer runs. Read git state with `git --no-optional-locks status` / `git --no-optional-locks diff`, which never takes it, and before finishing check `ls .git/*.lock` and clear anything left. If deletion is refused, request it rather than leaving the repository wedged.\n- Maintainer: run Checkstyle on every changed Java file before handing work off, even without Gradle. Use the version in\n  `gradle/libs.versions.toml` (`checkstyle-<version>-all.jar` from the Checkstyle GitHub releases; 14.x needs Java 21+) as\n  `java -Dcheckstyle.suppressions.file=style/checkstyle-suppressions.xml -Dcheckstyle.importcontrol.file=style/import-control.xml\n  -jar checkstyle.jar -c style/checkstyle-rules.xml <files>`. When the workspace JDK is older, tar the files and `style/` into\n  `build/` (deletable without asking) and run it where Java 21 is available.\n","frontmatter":{"name":"cas-handoff","description":"Finish and hand off a change in the CAS repository: Checkstyle on changed Java files, no stale .git/index.lock, release-notes and puppeteer cross-checks, and the ready-to-use commit message. Use at the end of every task that changed files in cas-server, before reporting the work as done."},"isInternal":false,"tokens":689,"sizeBytes":2846},{"name":"SKILL.md","path":".claude/skills/cas-heimdall-authzen/SKILL.md","rawUrl":"https://raw.githubusercontent.com/apereo/cas/HEAD/.claude/skills/cas-heimdall-authzen/SKILL.md","title":"cas-heimdall-authzen","category":"anthropic-skill","format":"markdown","content":"---\nname: cas-heimdall-authzen\ndescription: \"Review and change Heimdall authorization and its AuthZEN PDP endpoints (support/cas-server-support-heimdall*): request parsing, caller authentication, policies (JDBC, JSON resources), evaluations, PDP metadata, throttling and the heimdall-authzen/heimdall-nginx scenarios. Use when touching Heimdall code, docs or scenarios.\"\n---\n\n# Heimdall and AuthZEN\n\n## Heimdall / AuthZEN review discipline\n\n- Check the latest OpenID Authorization API final specification and editor's draft. AuthZEN is a PEP/PDP\n  protocol; wallet exchange uses OID4VCI/OID4VP. Treat a verified-presentation-to-policy bridge as a feature.\n- Trace `DefaultAuthorizationPrincipalParser` through `DefaultAuthorizationEngine` and each policy.\n  A valid caller token does not establish PDP audience, PEP permissions or DPoP proof possession;\n  delegated subject ids are legitimate for trusted PEPs and are not inherently impersonation.\n- An AuthZEN `resource.id` names the resource instance, never a policy namespace. AuthZEN requests\n  (`AuthorizationRequest.isAuthZen()`: subject, resource and action all present) match `resourceType`, `actions` and an\n  optional full-match `resourceIdPattern` across every namespace, and every match must grant. Normal requests\n  keep namespace + URI pattern + method and `/heimdall/authorize` rejects AuthZEN fields, which keeps the paths apart.\n- Callers authenticate with tokens issued to a registered OAuth/OIDC service whose access strategy allows\n  access; `HeimdallRegisteredServiceAccessStrategy` (alone or chained) can refuse it. DPoP and `x509_digest`\n  certificate bindings are enforced. `x509_digest` is the RFC 8705 `x5t#S256` thumbprint (base64url SHA-256 of the\n  DER certificate); compute it only with `OAuth20Utils.computeCertificateThumbprint` so issuer and verifiers agree. On AuthZEN, Basic means `client_id:client_secret` (reject clients without secrets:\n  `DefaultOAuth20ClientSecretValidator.validate` returns true when none is defined); on `/heimdall/authorize` it\n  stays CAS user credentials, split on the first colon. Both endpoints are throttled; `HeimdallThrottledHandlerInterceptor`\n  forwards only 401s to the throttle interceptors' post-processing. A `ThrottledRequestFilter` cannot limit what\n  counts: the default `httpPost()` filter claims every POST and the plan combines filters with `anyMatch`, and the\n  interceptors record every non-2xx response (twice: `postHandle` and `afterCompletion`; harmless for decisions,\n  since stores keep the latest failure per key or read the authentication audit trail).\n  Create registry-backed single-use markers through the `TicketFactory` (TST factory, custom `ExpirationPolicy` in\n  a *mutable* properties map: `buildExpirationPolicy` removes that key, so `Map.of` throws), never by instantiating\n  ticket implementations.\n- macOS JDKs use a polling `WatchService` (2s): a file created and deleted between two polls produces no event.\n  Watcher-driven caches must reconcile against the file system on every event rather than trust event kinds. JWT assertions need `jti`/`iat`, are single-use,\n  and share the token endpoint's audiences on purpose. A resource without policies denies.\n- JDBC trusted parameters (`principal`, `method`, `uri`, `namespace` and the AuthZEN names) are added after\n  context and attributes, so they already win; an overwrite claim there was a false finding. Check that policy\n  file deletion removes cached grants. `enforceAllPolicies=false` means any one policy grants: a failing policy is\n  logged and skipped, and failures are rethrown only when nothing grants (never turn them into a silent deny).\n  `enforceAllPolicies=true` stops at the first denial or failure. An empty policy list denies before either runs.\n- JDBC policies resolve their `DataSource` by bean name (`dataSourceName`, else `heimdallJdbcDataSource-<sha256(url|username)>`)\n  and register a `JpaBeans.newPoolingDataSource` pool (Hikari defaults, `minimumIdle=0`) through\n  `GenericApplicationContext.registerBean` under a lock, so Spring closes it on shutdown. Never cache a pool on a\n  policy instance (policies are rebuilt on every file reload) and never use `registerSingleton` for closeable beans:\n  Spring gives registered singletons no destruction callbacks. The pool key ignores the password (a rotated password\n  needs a new `dataSourceName` or a restart; documented). `queryTimeout` (default `PT5S`) is set on the `JdbcTemplate`. Tests override `resolveApplicationContext()` with a\n  local context rather than relying on the static `ApplicationContextProvider`, which parallel tests share.\n- `JsonAuthorizableResourceRepository` publishes one immutable `ResourceIndex` (by namespace and by AuthZEN\n  `resourceType`) per reload; AuthZEN lookups use the type index, so keep both maps in the same snapshot.\n- Policies evaluate sequentially; do not reintroduce `parallelStream()` (blocking policies starve `commonPool`).\n- AuthZEN subjects are resolved through the principal resolver only for subject type `user` (hardcoded);\n  other types become a bare principal. Policies read request data through\n  `AuthorizationRequest.resolveAttributeValues` (qualified `subject.*`, `resource.*`, `action.*`, `context.*` names);\n  never merge caller-supplied properties into principal attributes, which would let a PEP override the directory.\n  AuthZEN `context` is body-only; `/heimdall/authorize` adds non-protocol headers with `putIfAbsent`.\n- Palantir rebuilds resources from a fixed field list (`heimdallResourceForStorage`) and re-saves the whole\n  namespace; any new `AuthorizableResource` field must be added there or an edit silently drops it.\n- AuthZEN PDP metadata: the identifier is `<prefix>/heimdall`, served at `/cas/heimdall/.well-known/authzen-configuration`\n  (`HeimdallAuthZenConfigurationController`). The spec inserts the well-known segment after the host\n  (`/.well-known/authzen-configuration/cas/heimdall`), outside the CAS context: deployments need a proxy or ENGINE\n  rewrite-valve rule, as in the `heimdall-authzen` scenario's `rewrite.config`. `policy_decision_point` must equal the\n  identifier the PEP started from. List only endpoints that exist (no search yet).\n- AuthZEN decision context: denials carry `context.reason` from `AuthorizationDecisionReason` (set by the engine:\n  `subject_unresolved`, `no_matching_resource`, `no_policies`, `policy_denied`); grants carry no context. Never put\n  policy names, attribute names or messages in it; details go to DEBUG logs. Search (R5) and the OID4VP wallet bridge\n  (R7) are deferred and noted as future work in the Heimdall and verifiable credentials docs.\n- AuthZEN evaluations (`/heimdall/authzen/evaluations`): top-level subject/resource/action/context are defaults that an\n  entry replaces per key. Authenticate the caller once (`authenticateCaller`), then `resolveSubject` per entry;\n  never call `parse` per entry (it would consume single-use JWT assertions). Per-entry failures are `decision:false` with\n  `context.error.{status,message}` (no exception text for 500s); short-circuit semantics omit the remaining entries;\n  no/empty `evaluations` falls back to the single evaluation. Evaluations run sequentially.\n- AuthZEN evaluated denials use HTTP 200 with `decision:false`; authentication failures use 401.\n  Discovery/batch/search are separate capabilities, and the specification's example endpoint path is not mandatory.\n- Read shared helpers before reporting leaks: request headers already filter credentials and the request\n  principal is JSON-ignored. Confirm performance severity with evidence; blocking policy parallel streams\n  and unpooled JDBC merit investigation, not an unmeasured claim of outage.\n- Puppeteer scenario `heimdall-authzen` covers the AuthZEN endpoint end to end (decisions across namespaces,\n  id patterns, deny-wins, empty policies, `X-Request-ID`, unknown fields, 400/401, the Heimdall access strategy,\n  client-credential and bearer PEPs, the legacy endpoint, the actuator and throttling). It also enables throttling,\n  so keep 401-producing steps at least one throttle window apart. Palantir scenarios only check that tabs load.\n- Scenario `heimdall-nginx` drives the shared nginx config (`ci/tests/nginx`) as a PEP: `/api` uses `auth_request`\n  against `/heimdall/authorize`. The subrequest must set `proxy_method POST` (it is a GET otherwise) and send a\n  `namespace`; nginx does not escape variables in `proxy_set_body`, so URIs with `\"` or `\\` are rejected by a `map`.\n  `auth_request` maps any status other than 2xx/401/403 (such as Heimdall's 404) to 500. Validate edits with a local\n  `nginx -t` and stub upstreams; the config resolves `host.docker.internal` at load time.\n","frontmatter":{"name":"cas-heimdall-authzen","description":"Review and change Heimdall authorization and its AuthZEN PDP endpoints (support/cas-server-support-heimdall*): request parsing, caller authentication, policies (JDBC, JSON resources), evaluations, PDP metadata, throttling and the heimdall-authzen/heimdall-nginx scenarios. Use when touching Heimdall code, docs or scenarios."},"isInternal":false,"tokens":2016,"sizeBytes":8717},{"name":"SKILL.md","path":".claude/skills/cas-import-pr/SKILL.md","rawUrl":"https://raw.githubusercontent.com/apereo/cas/HEAD/.claude/skills/cas-import-pr/SKILL.md","title":"cas-import-pr","category":"anthropic-skill","format":"markdown","content":"---\nname: cas-import-pr\ndescription: \"Bring an apereo/cas GitHub pull request onto a local branch without a git identity, keeping the contributor as author, by fetching each non-merge commit as a patch and applying it with git am. Use when asked to review, fix up or test a contributor PR locally.\"\n---\n\n# Importing a contributor pull request\n\n1. List the PR's commits (`https://api.github.com/repos/apereo/cas/pulls/<n>/commits`) and drop the\n   \"Merge branch 'master'\" commits.\n2. Cut a local branch from master.\n3. For each remaining commit, oldest first, fetch the patch and apply it as described below.\n4. Leave the result uncommitted beyond the contributor's own commits; the maintainer commits review fixes himself\n   (`cas-handoff` skill).\n\n## Environment limits\n\n- The remote shell has no git identity. To bring a contributor's PR onto a local branch, fetch each non-merge commit as a patch from `https://api.github.com/repos/apereo/cas/commits/<sha>` with `Accept: application/vnd.github.patch` and apply it with `git -c user.name=... -c user.email=... am --3way`, which keeps the contributor as author. Skip the \"Merge branch 'master'\" commits; the local branch is cut from master already.\n","frontmatter":{"name":"cas-import-pr","description":"Bring an apereo/cas GitHub pull request onto a local branch without a git identity, keeping the contributor as author, by fetching each non-merge commit as a patch and applying it with git am. Use when asked to review, fix up or test a contributor PR locally."},"isInternal":false,"tokens":282,"sizeBytes":1201},{"name":"SKILL.md","path":".claude/skills/cas-interrupt-notifications/SKILL.md","rawUrl":"https://raw.githubusercontent.com/apereo/cas/HEAD/.claude/skills/cas-interrupt-notifications/SKILL.md","title":"cas-interrupt-notifications","category":"anthropic-skill","format":"markdown","content":"---\nname: cas-interrupt-notifications\ndescription: \"Review and change interrupt notifications: InterruptWebflowConfigurer, inquirers, the tracking cookie, passive requests, SSO reuse by protocol modules. Use when touching support/cas-server-support-interrupt*.\"\n---\n\n# Interrupt notifications\n\n## Interrupt notifications\n\n- Review the whole webflow path in `InterruptWebflowConfigurer`, not a single action: inquiry is wired at several\n  states, and the request-scoped finalized marker decides whether later checkpoints re-run the inquirers.\n- Treat the tracking cookie as a user-held acknowledgement. Changes to finalize/tracking must keep blocked\n  responses unacknowledgeable and must consider which principal the cookie was issued for.\n- Puppeteer interrupt scenarios often log out between logins, which removes the tracking cookie; a regression step for tracking must log in again without logging out.\n- Passive requests (CAS `gateway`, OIDC `prompt=none`, SAML `IsPassive`) must not render the interrupt view. When adding an event to an action that is prepended to an existing action state, add the matching transition to that state; an unmatched event silently continues to the next action.\n- A check that only runs in the login webflow is bypassed wherever a protocol module reuses the SSO session directly (for example the SAML2 IdP). For interrupts this is accepted for now; do not close it by vetoing SSO participation from the interrupt module without agreeing on the approach with the maintainer.\n- Inquirer errors currently fail open; any fix must state the intended behavior for blocking policies.\n- Reloadable inquirer state (files, watchers) must be published atomically; a failed reload keeps the last good contents rather than failing open.\n- Payloads map onto `InterruptResponse`, whose no-arg constructor means \"interrupt\". Parse external responses only after the status check, and test error bodies against random-port `MockWebServer` instances.\n","frontmatter":{"name":"cas-interrupt-notifications","description":"Review and change interrupt notifications: InterruptWebflowConfigurer, inquirers, the tracking cookie, passive requests, SSO reuse by protocol modules. Use when touching support/cas-server-support-interrupt*."},"isInternal":false,"tokens":397,"sizeBytes":1968},{"name":"SKILL.md","path":".claude/skills/cas-ldap/SKILL.md","rawUrl":"https://raw.githubusercontent.com/apereo/cas/HEAD/.claude/skills/cas-ldap/SKILL.md","title":"cas-ldap","category":"anthropic-skill","format":"markdown","content":"---\nname: cas-ldap\ndescription: \"Review and change LDAP integration: LdapUtils, connection factories and pools, search result handling, password transport, fail-open authorization, logging of directory data. Use when touching LDAP authentication, attribute repositories, password management or any LDAP-backed module.\"\n---\n\n# LDAP\n\n## LDAP review discipline\n\n- Start at the shared seam. `LdapUtils` builds connection configs, filters, authenticators, DN/entry resolvers\n  and person-attribute DAOs for every LDAP-backed feature; `LdapConnectionFactory` wraps every search, modify,\n  add, delete and password-modify. Findings there generalize; findings in one feature module usually do not.\n- Follow the whole flow: property model -> `LdapUtils` construction -> auto-configuration bean -> the calling\n  repository or handler -> the webflow/endpoint boundary that consumes the result. Most real defects live in\n  the mismatch between two of those layers, not inside one class.\n- Search results are a trust boundary. Check the LDAP result code, not just whether an entry came back, and\n  check what happens on truncation (server size/time/admin limits), on referral or continuation references,\n  and on multi-valued attributes that directories return in ranges.\n- Connection factories are pooled and stateful. Confirm they are created once as beans, that a pool used for\n  user binds is passivated back to its original bind state, and that maps of factories are keyed by something\n  that actually distinguishes two configurations.\n- For any code that reads or writes a password, verify the transport requirement against the current RFC text\n  and confirm the guard is enforcement rather than a log line; read the boolean carefully for inverted sense.\n- Treat a fail-open authorization branch as critical regardless of how it is reached. Trace the default value\n  of every property the branch depends on, including Spring Boot defaults CAS does not override.\n- Watch for values that are written by one path and compared by another, and for filter/template parameters\n  that are silently ignored when the operator's template does not reference them.\n- Secrets and directory internals must not reach logs or released attributes: password-reset answers, bind\n  credentials, diagnostic messages and matched DNs all deserve a second look.\n","frontmatter":{"name":"cas-ldap","description":"Review and change LDAP integration: LdapUtils, connection factories and pools, search result handling, password transport, fail-open authorization, logging of directory data. Use when touching LDAP authentication, attribute repositories, password management or any LDAP-backed module."},"isInternal":false,"tokens":463,"sizeBytes":2330},{"name":"SKILL.md","path":".claude/skills/cas-mcp-server/SKILL.md","rawUrl":"https://raw.githubusercontent.com/apereo/cas/HEAD/.claude/skills/cas-mcp-server/SKILL.md","title":"cas-mcp-server","category":"anthropic-skill","format":"markdown","content":"---\nname: cas-mcp-server\ndescription: \"Review and extend the CAS MCP server (support/cas-server-support-mcp): @McpTool beans, security and auditing, optional tool groups, typed results and output schemas, secret redaction, resources and completion, tests and the mcp-server-* scenarios. Use when adding or changing MCP tools, resources or prompts.\"\n---\n\n# CAS MCP server module\n\n## MCP server module (`support/cas-server-support-mcp`)\n\n- Tools are plain beans whose methods carry `@McpTool` plus `@PreAuthorize(\"hasRole('MCP_ADMIN')\")`;\n  `CasMcpToolsTests.verifyToolsAreSecuredByProxies` asserts every such bean is an AOP proxy. Read tools need `MCP_ADMIN`.\n- Tools that change CAS (`AI.mcp-write`, off by default) live in separate `*AdministrationTools` beans so the toggle can drop\n  them: core ones in `CasAdministrationTools` (sessions with SLO through `SingleLogoutRequestExecutor`, throttle release via\n  `ThrottledSubmissionsStore.removeIf`, `setServiceEnabled` on `DefaultRegisteredServiceAccessStrategy` including chained\n  ones, `setLogLevel` through Spring Boot's `LoggingSystem`), optional ones as extra `@Bean` methods in the group's nested\n  configuration, or a nested configuration of their own (`CasMcpOAuthAdministrationConfiguration`). Each bean method carries\n  `@ConditionalOnFeatureEnabled(module = \"mcp-write\", enabledByDefault = false)`, each tool `hasRole('MCP_OPERATOR')`,\n  `readOnlyHint = false` and `destructiveHint = true` unless harmless. They accept session references and record ids from\n  the read tools, never raw TGT or token ids. Tests: `CasMcpToolsTests` turns `mcp-write` on (bean level, `MCP_OPERATOR`\n  mock user, Mockito `verify` on the mocked MFA repositories), `CasMcpServerTests.WriteToolsTests` (MockMvc, `mcpoperator`,\n  tokens), and `ProtocolTests` asserts they are not listed by default. Checkstyle rejects `public LoggerXxx` and a parameter\n  named `logger` (non-static logger rule).\n- A tool over an optional feature follows the consent pattern: `compileOnly` (and `testImplementation`) on that module, a\n  nested `@Configuration` with `@ConditionalOnClass` and feature module `mcp-<group>`, an `ObjectProvider` for the backing\n  bean and a `reason` in the result when it is absent; test activation in `CasMcpAutoConfigurationTests`.\n- Never return secrets: device fingerprints, trusted-device record keys, client secrets, key material, setting values.\n  Tests assert the secret string is absent from the result.\n- A turned-off events repository is a `BeanSupplier` proxy, not null: check `BeanSupplier.isProxy`.\n- Every `@McpTool`/`@McpResource` call is audited by one infrastructure advisor (`CasMcpAuditInterceptor`, ordered before\n  method security so denials are recorded) that hands a `CasMcpOperation` (an `AuditableEntity`, principal = the MCP\n  user) to the `@Audit`-annotated `CasMcpAuditor`. New tools are covered automatically; never add `@Audit` to tool\n  methods (the default audit principal resolver would take the first argument, the person being looked up).\n- Tools summarize; they never return serialized CAS objects (tickets, services' secrets) or attribute values outside\n  findPerson/previewAttributeRelease. Ticket-granting ticket ids are bearer tokens: return\n  `CasMcpSessionReferences.of(id)` and accept references back through `CasMcpSessionReferences.resolve` (with the\n  username). Service JSON from findService/matchService goes through the secret redaction in `CasServiceManagementTools`.\n- MCP capabilities are set in `webapp/cas-server-webapp-resources/src/main/resources/application.properties`\n  (`spring.ai.mcp.server.capabilities.*`) and mirrored in `BaseCasMcpTests`; a `@McpResource` or `@McpPrompt` bean is\n  silently not advertised while its capability is false. Resources (`org.apereo.cas.mcp.resources`) return JSON built\n  with a plain `JsonMapper`: CAS's `JacksonObjectMapperFactory` mappers use `NON_DEFAULT` inclusion and drop `false`/`0`.\n- `DefaultAuthenticationRiskEvaluator` is `@Transactional` on `transactionManagerEvents`; test contexts that use a real\n  evaluator need that bean (`PseudoTransactionManager`, as `CasCoreEventsAutoConfiguration` registers it).\n- Tools that evaluate a person at an application build their subject with `CasMcpEvaluationContextFactory.build`\n  (service, matching registered service, resolved principal, `authentication()`); request-aware components get\n  `currentHttpExchange()`, the live request with parameters, headers, cookies and query string hidden, or\n  `newWebflowRequestContext(context)`, a webflow request context over it with service, registered service, credential\n  and authentication in scope. Do not resolve services or principals by hand in a tool. Tools that need client details\n  (IP address, user agent, location) take them as parameters and wrap them in `CasMcpClient`: `ClientInfoHolder`\n  describes the MCP operator. `currentHttpExchange(client)` exposes them as remote address, `User-Agent` and the\n  `geolocation` parameter; `withClientInfo(client, ...)` swaps `ClientInfoHolder` only around components that read it\n  (MFA triggers). Do not swap it around audited calls such as the risk evaluator, or the audit trail records the\n  supplied address instead of the operator's.\n- A new tool also goes into `CAS_TOOLS` (`CasMcpServerTests`, `mcp-server-tools/script.js` when core), the role-denial\n  lists, `FeatureDisabledTests`, and the tables and feature-toggle list in `Admin-MCP-Server.md`. A new optional group also\n  goes into `CasDeploymentTools.MCP_TOOL_GROUPS` (feature module to bean name), which `describeDeployment` reports.\n- Tools return typed records with `@McpTool(generateOutputSchema = true)` (only findService/matchService return free-form\n  JSON strings): tools/list then carries an `outputSchema` and calls return `structuredContent`, which the MCP SDK validates\n  against the schema and copies into the text content. Mark optional components `@Nullable` (jspecify) and annotate the\n  record `@JsonInclude(NON_NULL)`: non-null components are `required` in the schema and a `null` fails validation. Never\n  return `null` from a typed tool; return the record with a `reason`. Keep JSON names stable (puppeteer reads them), use\n  `String` for dates, `Object` for nested CAS objects whose shape should not become schema, `@JsonProperty` for Java\n  keywords such as `default`, and Lombok `@Builder` for records with many optional components. Bean-level tests use the\n  record accessors.\n- A tool that combines others (explainLogin) calls the other tool beans and converts their records to maps with a plain\n  `JsonMapper`. `CasMcpAuditInterceptor` binds a `ScopedValue` for the outermost call, so nested tool calls are not audited.\n- Puppeteer builds CAS with `-DskipNestedConfigMetadataGen=true`, so the settings catalog at runtime holds only third-party\n  (Spring) settings and no `cas.*` ones. Scenarios exercise settings tools with a Spring setting such as\n  `spring.ai.mcp.server.name`; `cas.*` settings and the `module` filter are covered by unit tests (`McpSettingsMetadata.json`).\n- MCP completion (`@McpComplete`) only completes resource-template and prompt arguments, never tool parameters. Completion\n  methods carry `@PreAuthorize` like tools and are not audited; `spring.ai.mcp.server.capabilities.completion` must be true.\n- Claude's shells have no JDK 25 or Maven Central; `build/mcp-run.sh` compiles, runs checkstyle, the `RestfulApi` tests\n  and the MCP puppeteer scenarios on the Mac.\n- OAuth resource checks use `OAuth20RequestParameterResolver.resolveAllowedResources(service)`, which adds the CAS-wide\n  `cas.authn.oauth.core.allowed-resources` (default: the CAS MCP endpoint) to the service's `allowedResources`; never\n  match against `getAllowedResources()` alone. `AbstractOAuth20Tests` and `BaseCasMcpTests` allow `https://cas.example.org/cas/mcp`.\n- Bearer access tokens (`AI.mcp-oauth`, nested `CasMcpOAuthConfiguration`, needs OIDC for the issuer): the filter\n  `CasMcpBearerTokenAuthenticationFilter` is built inside a `CasWebSecurityConfigurer`, never exposed as a bean (Spring Boot\n  registers `Filter` beans for every request). It sits before `BasicAuthenticationFilter`, acts only on the MCP path with a\n  `Bearer` header, sets a `CasMcpBearerTokenAuthentication` (principal id, client id, scopes) without saving it, and grants\n  `ROLE_MCP_ADMIN` only from `cas.mcp.oauth.required-attributes` (empty matches nobody), and `ROLE_MCP_OPERATOR` only with the\n  `mcp:write` scope plus `cas.mcp.oauth.operator-required-attributes` (`grantRoles`), checked against attributes resolved\n  through `defaultPrincipalResolver` (token principal as current principal): a token's principal carries only the attributes\n  released to the client, so `memberOf` and the like are usually missing there. The unauthenticated challenge comes from\n  `defaultAuthenticationEntryPointFor(..., mcp matcher)`, registered before `httpBasic` initializes so it wins. Being first\n  also makes it Spring Security's fallback for requests no entry point claims (browser requests to actuators), so outside\n  the MCP path it answers with the Basic challenge alone. A configurer must never set an explicit `authenticationEntryPoint`,\n  which replaces every other one; Palantir's CAS login registers its entry point with `defaultAuthenticationEntryPointFor`\n  for `/palantir/**` only (`DashboardControllerTests.CasAuthenticationTests`). The security context is saved in a request attribute only (async SSE dispatches need\n  it), never in the HTTP session.\n- Spring Security's Basic challenge is `Basic realm=\"Realm\", charset=\"UTF-8\"` (MockMvc reports it as more than one value), so\n  `CasMcpAuthenticationEntryPoint` delegates to `BasicAuthenticationEntryPoint` rather than writing it, and tests compare with\n  that entry point's output (`CasMcpServerTests.basicChallenge`). `jsonPath(...).value(List.of(...))` reads `null` because the\n  expected value's class (`List.of`'s immutable type) becomes the target type; assert `[0]` and `length()` instead. Protected resource metadata lives under the CAS\n  context (`/cas/.well-known/oauth-protected-resource/mcp`), because the root well-known path is outside the web app; clients\n  reach it through `resource_metadata`. Tests build `OAuth20DefaultAccessToken` directly and add it to the registry\n  (`CasMcpServerTests.newAccessToken`); `mcp-server-saml-oidc` gets a JWT token through the password grant.\n- `CasMcpEvaluationContextFactory` hands the username to the principal resolvers as the current principal. Without attribute repositories the chain holds only `EchoingPrincipalResolver`, which echoes the current principal, so resolving without one yields `NullPrincipal` (`nobody`).\n- OAuth/OIDC services serialize their secret as `clientSecrets` (a list of objects with `value`); `getClientSecret()` is `@JsonIgnore`. Redaction of service definitions must cover the plural property, which `SECRET_PROPERTY` in `CasServiceManagementTools` does with `secrets?`.\n- `CasOidcTools.checkOidcClientResource` reuses `RegisteredServiceOAuthResourceMatcher` (`isValidResource`,\n  `findAllowedResource`) so the tool and the protocol endpoints decide resource indicators the same way; new tools are\n  added to `CasMcpServerTests.CAS_TOOLS` and to the optional-tool lists of the `mcp-server-*` scenarios.\n","frontmatter":{"name":"cas-mcp-server","description":"Review and extend the CAS MCP server (support/cas-server-support-mcp): @McpTool beans, security and auditing, optional tool groups, typed results and output schemas, secret redaction, resources and completion, tests and the mcp-server-* scenarios. Use when adding or changing MCP tools, resources or prompts."},"isInternal":false,"tokens":2628,"sizeBytes":11308},{"name":"SKILL.md","path":".claude/skills/cas-oauth-oidc-authentication/SKILL.md","rawUrl":"https://raw.githubusercontent.com/apereo/cas/HEAD/.claude/skills/cas-oauth-oidc-authentication/SKILL.md","title":"cas-oauth-oidc-authentication","category":"anthropic-skill","format":"markdown","content":"# cas-oauth-oidc-authentication\n\nPath: `.claude/skills/cas-oauth-oidc-authentication/SKILL.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/apereo/cas/HEAD/.claude/skills/cas-oauth-oidc-authentication/SKILL.md)","tokens":300,"sizeBytes":229},{"name":"SKILL.md","path":".claude/skills/cas-oauth-oidc/SKILL.md","rawUrl":"https://raw.githubusercontent.com/apereo/cas/HEAD/.claude/skills/cas-oauth-oidc/SKILL.md","title":"cas-oauth-oidc","category":"anthropic-skill","format":"markdown","content":"# cas-oauth-oidc\n\nPath: `.claude/skills/cas-oauth-oidc/SKILL.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/apereo/cas/HEAD/.claude/skills/cas-oauth-oidc/SKILL.md)","tokens":300,"sizeBytes":184},{"name":"SKILL.md","path":".claude/skills/cas-oid4vc/SKILL.md","rawUrl":"https://raw.githubusercontent.com/apereo/cas/HEAD/.claude/skills/cas-oid4vc/SKILL.md","title":"cas-oid4vc","category":"anthropic-skill","format":"markdown","content":"# cas-oid4vc\n\nPath: `.claude/skills/cas-oid4vc/SKILL.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/apereo/cas/HEAD/.claude/skills/cas-oid4vc/SKILL.md)","tokens":300,"sizeBytes":172},{"name":"SKILL.md","path":".claude/skills/cas-passwordless-webauthn/SKILL.md","rawUrl":"https://raw.githubusercontent.com/apereo/cas/HEAD/.claude/skills/cas-passwordless-webauthn/SKILL.md","title":"cas-passwordless-webauthn","category":"anthropic-skill","format":"markdown","content":"# cas-passwordless-webauthn\n\nPath: `.claude/skills/cas-passwordless-webauthn/SKILL.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/apereo/cas/HEAD/.claude/skills/cas-passwordless-webauthn/SKILL.md)","tokens":300,"sizeBytes":217},{"name":"SKILL.md","path":".claude/skills/cas-protocol/SKILL.md","rawUrl":"https://raw.githubusercontent.com/apereo/cas/HEAD/.claude/skills/cas-protocol/SKILL.md","title":"cas-protocol","category":"anthropic-skill","format":"markdown","content":"# cas-protocol\n\nPath: `.claude/skills/cas-protocol/SKILL.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/apereo/cas/HEAD/.claude/skills/cas-protocol/SKILL.md)","tokens":300,"sizeBytes":178},{"name":"SKILL.md","path":".claude/skills/cas-puppeteer-scenarios/SKILL.md","rawUrl":"https://raw.githubusercontent.com/apereo/cas/HEAD/.claude/skills/cas-puppeteer-scenarios/SKILL.md","title":"cas-puppeteer-scenarios","category":"anthropic-skill","format":"markdown","content":"# cas-puppeteer-scenarios\n\nPath: `.claude/skills/cas-puppeteer-scenarios/SKILL.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/apereo/cas/HEAD/.claude/skills/cas-puppeteer-scenarios/SKILL.md)","tokens":300,"sizeBytes":211},{"name":"SKILL.md","path":".claude/skills/cas-saml/SKILL.md","rawUrl":"https://raw.githubusercontent.com/apereo/cas/HEAD/.claude/skills/cas-saml/SKILL.md","title":"cas-saml","category":"anthropic-skill","format":"markdown","content":"# cas-saml\n\nPath: `.claude/skills/cas-saml/SKILL.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/apereo/cas/HEAD/.claude/skills/cas-saml/SKILL.md)","tokens":300,"sizeBytes":166},{"name":"SKILL.md","path":".claude/skills/cas-sandbox-verification/SKILL.md","rawUrl":"https://raw.githubusercontent.com/apereo/cas/HEAD/.claude/skills/cas-sandbox-verification/SKILL.md","title":"cas-sandbox-verification","category":"anthropic-skill","format":"markdown","content":"# cas-sandbox-verification\n\nPath: `.claude/skills/cas-sandbox-verification/SKILL.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/apereo/cas/HEAD/.claude/skills/cas-sandbox-verification/SKILL.md)","tokens":300,"sizeBytes":214},{"name":"SKILL.md","path":".claude/skills/cas-service-registries/SKILL.md","rawUrl":"https://raw.githubusercontent.com/apereo/cas/HEAD/.claude/skills/cas-service-registries/SKILL.md","title":"cas-service-registries","category":"anthropic-skill","format":"markdown","content":"# cas-service-registries\n\nPath: `.claude/skills/cas-service-registries/SKILL.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/apereo/cas/HEAD/.claude/skills/cas-service-registries/SKILL.md)","tokens":300,"sizeBytes":208},{"name":"SKILL.md","path":".claude/skills/cas-session-stores/SKILL.md","rawUrl":"https://raw.githubusercontent.com/apereo/cas/HEAD/.claude/skills/cas-session-stores/SKILL.md","title":"cas-session-stores","category":"anthropic-skill","format":"markdown","content":"# cas-session-stores\n\nPath: `.claude/skills/cas-session-stores/SKILL.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/apereo/cas/HEAD/.claude/skills/cas-session-stores/SKILL.md)","tokens":300,"sizeBytes":196},{"name":"SKILL.md","path":".claude/skills/cas-startup-performance/SKILL.md","rawUrl":"https://raw.githubusercontent.com/apereo/cas/HEAD/.claude/skills/cas-startup-performance/SKILL.md","title":"cas-startup-performance","category":"anthropic-skill","format":"markdown","content":"# cas-startup-performance\n\nPath: `.claude/skills/cas-startup-performance/SKILL.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/apereo/cas/HEAD/.claude/skills/cas-startup-performance/SKILL.md)","tokens":300,"sizeBytes":211},{"name":"SKILL.md","path":".claude/skills/cas-storage-backends/SKILL.md","rawUrl":"https://raw.githubusercontent.com/apereo/cas/HEAD/.claude/skills/cas-storage-backends/SKILL.md","title":"cas-storage-backends","category":"anthropic-skill","format":"markdown","content":"# cas-storage-backends\n\nPath: `.claude/skills/cas-storage-backends/SKILL.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/apereo/cas/HEAD/.claude/skills/cas-storage-backends/SKILL.md)","tokens":300,"sizeBytes":202},{"name":"SKILL.md","path":".claude/skills/cas-testing/SKILL.md","rawUrl":"https://raw.githubusercontent.com/apereo/cas/HEAD/.claude/skills/cas-testing/SKILL.md","title":"cas-testing","category":"anthropic-skill","format":"markdown","content":"# cas-testing\n\nPath: `.claude/skills/cas-testing/SKILL.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/apereo/cas/HEAD/.claude/skills/cas-testing/SKILL.md)","tokens":300,"sizeBytes":175},{"name":"SKILL.md","path":".claude/skills/cas-ticket-registries/SKILL.md","rawUrl":"https://raw.githubusercontent.com/apereo/cas/HEAD/.claude/skills/cas-ticket-registries/SKILL.md","title":"cas-ticket-registries","category":"anthropic-skill","format":"markdown","content":"# cas-ticket-registries\n\nPath: `.claude/skills/cas-ticket-registries/SKILL.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/apereo/cas/HEAD/.claude/skills/cas-ticket-registries/SKILL.md)","tokens":300,"sizeBytes":205},{"name":"SKILL.md","path":".claude/skills/cas-ui/SKILL.md","rawUrl":"https://raw.githubusercontent.com/apereo/cas/HEAD/.claude/skills/cas-ui/SKILL.md","title":"cas-ui","category":"anthropic-skill","format":"markdown","content":"# cas-ui\n\nPath: `.claude/skills/cas-ui/SKILL.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/apereo/cas/HEAD/.claude/skills/cas-ui/SKILL.md)","tokens":300,"sizeBytes":160},{"name":"SKILL.md","path":".claude/skills/cas-web-security/SKILL.md","rawUrl":"https://raw.githubusercontent.com/apereo/cas/HEAD/.claude/skills/cas-web-security/SKILL.md","title":"cas-web-security","category":"anthropic-skill","format":"markdown","content":"# cas-web-security\n\nPath: `.claude/skills/cas-web-security/SKILL.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/apereo/cas/HEAD/.claude/skills/cas-web-security/SKILL.md)","tokens":300,"sizeBytes":190},{"name":"CLAUDE.md","path":"CLAUDE.md","rawUrl":"https://raw.githubusercontent.com/apereo/cas/HEAD/CLAUDE.md","title":"Claude Agent Guidelines & System Prompt","category":"claude-rule","format":"markdown","content":"# Claude Agent Guidelines & System Prompt\n\nPath: `CLAUDE.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/apereo/cas/HEAD/CLAUDE.md)","tokens":300,"sizeBytes":151},{"name":"copilot-instructions.md","path":".github/copilot-instructions.md","rawUrl":"https://raw.githubusercontent.com/apereo/cas/HEAD/.github/copilot-instructions.md","title":"GitHub Copilot Instructions","category":"copilot-instructions","format":"markdown","content":"# GitHub Copilot Instructions\n\nPath: `.github/copilot-instructions.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/apereo/cas/HEAD/.github/copilot-instructions.md)","tokens":300,"sizeBytes":183},{"name":".cursorrules","path":".cursorrules (testrail-test-case-cursorrules-prompt-file.mdc)","rawUrl":"https://raw.githubusercontent.com/PatrickJS/awesome-cursorrules/main/rules/testrail-test-case-cursorrules-prompt-file.mdc","title":"Awesome Cursorrules: testrail test case","category":"cursor-rule","format":"markdown","content":"---\ndescription: \"Cursor rules for TestRail development with test case integration.\"\nglobs: **/*\nalwaysApply: false\n---\n# Persona\n\nYou are an experienced QA Engineer with expertise in writing clear, detailed test cases\nfor TestRail that help testers efficiently execute tests and validate functionality.\nYou understand testing methodologies and how to structure test cases that provide\ncomprehensive coverage while remaining maintainable and reusable.\n\n# Documentation Focus\n\nFocus on creating standardized TestRail test cases with these key components:\n\n- Clear, descriptive test case title\n- Concise test case objective/purpose\n- Preconditions required for test execution\n- Step-by-step test procedure with expected results\n- Test data requirements\n- Post-conditions after test execution\n- Appropriate custom fields for TestRail\n\n# Best Practices\n\n**1** **Clear Title**: Create descriptive, specific test case titles\n**2** **Complete Preconditions**: List all necessary setup requirements\n**3** **Numbered Steps**: Present test steps in a clear, sequential order\n**4** **Explicit Expected Results**: Specify precise expected outcomes for each step\n**5** **Appropriate Test Type**: Assign the correct test type (functional, regression, etc.)\n**6** **Test Data**: Include specific test data values where applicable\n**7** **Environment Details**: Specify relevant environment information\n**8** **Organized Sections**: Group related test cases in logical sections\n\n# TestRail CSV Format Example\n\n```csv\nTitle,Section,Type,Priority,Preconditions,Steps,Expected Results\n\"Login with Valid Credentials\",\"User Authentication\",\"Functional\",\"Critical\",\"User has valid account credentials\",\"1. Navigate to the login page.\n2. Enter valid username 'testuser'.\n3. Enter valid password 'Password123'.\n4. Click the 'Login' button.\",\"1. Login page loads correctly.\n2. Username field accepts input.\n3. Password field accepts input.\n4. User is successfully logged in and redirected to the dashboard.\"\n\"Login with Invalid Password\",\"User Authentication\",\"Functional\",\"High\",\"User has valid account credentials\",\"1. Navigate to the login page.\n2. Enter valid username 'testuser'.\n3. Enter invalid password 'wrongpassword'.\n4. Click the 'Login' button.\",\"1. Login page loads correctly.\n2. Username field accepts input.\n3. Password field accepts input.\n4. Error message is displayed: 'Invalid username or password'.\"\n\"Password Reset Request\",\"User Authentication\",\"Functional\",\"Medium\",\"User has registered email address\",\"1. Navigate to the login page.\n2. Click the 'Forgot Password' link.\n3. Enter valid email 'test@example.com'.\n4. Click the 'Reset Password' button.\",\"1. Login page loads correctly.\n2. Forgot password page loads correctly.\n3. Email field accepts input.\n4. Success message is displayed: 'Password reset link sent'.\"\n```\n\n# Converting Automated Tests to TestRail Format\n\nWhen converting automated tests or feature descriptions to TestRail format:\n\n1. Identify the overall test objective and create a descriptive title\n2. Extract preconditions from setup code or implied requirements\n3. Convert test actions into numbered steps\n4. Transform assertions into expected results\n5. Group related test cases into sections\n6. Assign appropriate test types and priorities\n7. Include specific test data values\n8. Add environmental notes if necessary\n\nExample:\n\nAutomated Test:\n\n```js\ndescribe('Login Functionality', () => {\n  it('should allow login with valid credentials', () => {\n    cy.visit('/login');\n    cy.get('#username').type('testuser');\n    cy.get('#password').type('Password123');\n    cy.get('#loginButton').click();\n    cy.url().should('include', '/dashboard');\n    cy.get('.welcome-message').should('contain', 'Welcome, testuser');\n  });\n});\n```\n\nTestRail CSV Format:\n\n```csv\nTitle,Section,Type,Priority,Preconditions,Steps,Expected Results\n\"Login with Valid Credentials\",\"User Authentication\",\"Functional\",\"Critical\",\"User has valid account credentials\",\"1. Navigate to the login page.\n2. Enter valid username 'testuser'.\n3. Enter valid password 'Password123'.\n4. Click the 'Login' button.\",\"1. User is redirected to the dashboard page.\n2. Welcome message is displayed: 'Welcome, testuser'.\"\n```\n\n# Test Case Structure\n\nStructure TestRail test cases using this format:\n\n```\n# Test Case: [Descriptive title]\n\n## Section\n[Section/Module/Feature]\n\n## Priority\n[Critical/High/Medium/Low]\n\n## Type\n[Functional/Regression/Usability/Performance/Security/etc.]\n\n## Objective\n[Clear statement of what the test aims to verify]\n\n## Preconditions\n1. [Precondition 1]\n2. [Precondition 2]\n...\n\n## Test Data\n- [Test data item 1: value]\n- [Test data item 2: value]\n...\n\n## Steps and Expected Results\n| # | Step | Expected Result |\n|---|------|----------------|\n| 1 | [Action to perform] | [Expected outcome] |\n| 2 | [Action to perform] | [Expected outcome] |\n...\n\n## Post-conditions\n1. [Post-condition 1]\n2. [Post-condition 2]\n...\n\n## Automation Status\n[Not Automated/To Be Automated/Automated]\n\n## References\n- [Requirement ID/User Story/Documentation Link]\n```\n\n# Example Test Case\n\nHere's an example of a well-structured TestRail test case:\n\n```\n# Test Case: User Login with Valid Credentials\n\n## Section\nAuthentication\n\n## Priority\nHigh\n\n## Type\nFunctional\n\n## Objective\nVerify that a user can successfully log in to the application using valid credentials.\n\n## Preconditions\n1. The application is accessible\n2. The test user account exists in the system\n3. The user is not currently logged in\n\n## Test Data\n- Username: test_user@example.com\n- Password: Test@123\n- User Role: Standard User\n\n## Steps and Expected Results\n| # | Step | Expected Result |\n|---|------|----------------|\n| 1 | Navigate to the login page | The login page is displayed with username and password fields, and a login button |\n| 2 | Enter valid username \"test_user@example.com\" in the username field | Username is accepted and displayed in the field |\n| 3 | Enter valid password \"Test@123\" in the password field | Password is accepted and masked in the field |\n| 4 | Click the \"Login\" button | The system authenticates the user and redirects to the dashboard |\n| 5 | Verify user information displayed in the header/profile section | Username \"test_user@example.com\" is displayed correctly |\n\n## Post-conditions\n1. User is logged in to the application\n2. User session is created\n3. User can access functionality based on their permissions\n\n## Automation Status\nAutomated\n\n## References\n- Requirement: REQ-AUTH-001\n- User Story: US-102\n```\n\n# Negative Test Case Example\n\nHere's an example of a negative test case:\n\n```\n# Test Case: User Login with Invalid Password\n\n## Section\nAuthentication\n\n## Priority\nHigh\n\n## Type\nFunctional\n\n## Objective\nVerify that the system correctly handles login attempts with an invalid password.\n\n## Preconditions\n1. The application is accessible\n2. The test user account exists in the system\n3. The user is not currently logged in\n\n## Test Data\n- Username: test_user@example.com\n- Password: WrongPassword123\n- User Role: Standard User\n\n## Steps and Expected Results\n| # | Step | Expected Result |\n|---|------|----------------|\n| 1 | Navigate to the login page | The login page is displayed with username and password fields, and a login button |\n| 2 | Enter valid username \"test_user@example.com\" in the username field | Username is accepted and displayed in the field |\n| 3 | Enter invalid password \"WrongPassword123\" in the password field | Password is accepted and masked in the field |\n| 4 | Click the \"Login\" button | The system displays an error message \"Invalid credentials. Please try again.\" |\n| 5 | Verify the user remains on the login page | The login page is still displayed with empty password field |\n| 6 | Verify the username field retains the entered username | Username \"test_user@example.com\" is still displayed in the field |\n\n## Post-conditions\n1. User remains logged out\n2. No user session is created\n3. Failed login attempt is logged in the system\n\n## Automation Status\nAutomated\n\n## References\n- Requirement: REQ-AUTH-002\n- User Story: US-103\n```\n\n# TestRail Specifics\n\nKeep these TestRail-specific considerations in mind:\n\n1. TestRail supports custom fields that may be specific to your organization\n2. TestRail allows for organization of test cases into sections and sub-sections\n3. Test cases can be added to test plans and assigned to testers\n4. TestRail allows for recording of test results and defects\n5. Automation status is often a key field for tracking automation coverage\n6. References to requirements, user stories, or other artifacts help with traceability\n\n# Test Case Writing Best Practices\n\nWhen writing TestRail test cases, follow these best practices:\n\n1. Use clear, descriptive titles that summarize what is being tested\n2. Write steps that are atomic, specific, and contain a single action\n3. Specify expected results for each step, not just the final outcome\n4. Include all necessary preconditions to ensure test reproducibility\n5. Specify concrete test data rather than vague descriptions\n6. Make test cases independent and self-contained when possible\n7. Use consistent language and terminology across all test cases\n8. Create reusable test cases that can be part of multiple test plans\n9. Include both positive and negative test scenarios\n10. Consider boundary values, equivalence partitions, and edge cases\n\n# Test Case Adaptation\n\nAdapt your test cases based on:\n\n- The specific product or feature being tested\n- Project-specific TestRail custom fields\n- Team-specific test case organization and naming conventions\n- Integration requirements with other tools (JIRA, DevOps, etc.)\n- Automation needs and frameworks\n\nWhen creating test cases, focus on providing clear guidance to testers\nwhile ensuring comprehensive coverage of functionality and edge cases.\n","tokens":2136,"sizeBytes":9799}],"systemPromptSnippet":"<yakaai_skills repo=\"apereo/cas\">\n<!-- File: AGENTS.md (Tokens: ~300 | Category: root-instruction) -->\n# AI Agent Protocol & Instructions\n\nPath: `AGENTS.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/apereo/cas/HEAD/AGENTS.md)\n\n<!-- File: .claude/skills/cas-actuators/SKILL.md (Tokens: ~1996 | Category: anthropic-skill) -->\n---\nname: cas-actuators\ndescription: \"Review and change CAS actuator endpoints: the security filter chain, access levels and IP access, REST actuator endpoints, exposure vs access, unmasked responses, request-path regexes, auditing and the actuator scenarios. Use when touching actuator endpoints or management security.\"\n---\n\n# Actuator endpoints\n\n## Actuator endpoints review discipline\n\n- There are ~63 endpoints. Inventory them with\n  `grep -rl --include=*.java -E \"@(RestControllerEndpoint|ControllerEndpoint|Endpoint|WebEndpoint)\\(id\"`;\n  `support/cas-server-support-reports-core` owns about a third, the rest are scattered across\n  feature modules. Review them as one attack surface, not one module at a time.\n- The whole security boundary is a single `SecurityFilterChain` built by\n  `CasWebSecurityConfigurerAdapter.configureHttpSecurity`. Rules are added in order --\n  ignored patterns (permitAll) -> each `CasWebSecurityConfigurer.configure` -> per-endpoint\n  `cas.monitor.endpoints.endpoint.*` -> `configureEndpointAccessToDenyUndefined` -> static\n  resources -> form login -- and Spring Security takes the first match, so anything a\n  `getIgnoredEndpoints` contributes wins over every endpoint rule that follows.\n- `prepareProtocolEndpoint` emits `/<ep>**` as well as `/<ep>/**`. The first form is a\n  within-segment wildcard, so an ignored endpoint also permits sibling paths sharing its\n  prefix. Check that before adding an entry.\n- That chain disables security headers and logout globally, and CSRF everywhere except the paths that configurers\n  declare through `getCsrfProtectedEndpoints()` (CIBA verification, WebAuthn). It is the same chain that serves the\n  actuator. Any browser-reachable session credential (`cas.monitor.endpoints.form-login-enabled`) therefore leaves\n  mutating actuator operations unprotected against cross-site requests.\n- `ActuatorEndpointProperties.access` defaults to `DENY` and the `endpoint` map always\n  carries a synthetic `defaults` key. The adapter iterates the map and calls\n  `EndpointRequest.to(\"defaults\")`, which resolves to no path and silently registers nothing;\n  the real default comes from `configureEndpointAccessToDenyUndefined`. Do not read the loop\n  as the place defaults are enforced.\n- `configureEndpointAccessToDenyUndefined` reads `management.endpoint.<rootPath>.access` with\n  a raw `Environment.getProperty` call and maps `UNRESTRICTED`/`READ_ONLY` to `permitAll`.\n  Relaxed spellings of that key are not seen (fail-closed), but the canonical spelling turns a\n  Boot-level exposure decision into anonymous Spring Security access.\n- `IP_ADDRESS` access is evaluated by `IpAddressAuthorizationManager`, which reads\n  `cas.audit.engine.alternate-client-addr-header-name` (default `X-Forwarded-For`) straight off the\n  request. This is intentional and is NOT a CAS defect: the fronting proxy, load balancer and\n  external boot configuration are expected to set that header as a trusted header, or to blank it\n  out and let Tomcat's own valves resolve the client address. Configuring that correctly is left to\n  the deployer, so do not report header spoofing here as a finding.\n- `BaseCasRestActuatorEndpoint` endpoints are NOT Spring Boot operations. They are discovered by\n  CAS's own `RestActuatorEndpointDiscoverer`, mapped by `RestActuatorEndpointHandlerMapping`, and\n  their handlers are Spring MVC mappings rather than `@ReadOperation`/`@WriteOperation` methods.\n  Boot applies an access level by filtering operations, and these endpoints declare none, so\n  `management.endpoint.<id>.access=READ_ONLY` does not stop their `@PostMapping`, `@PutMapping`,\n  `@PatchMapping` or `@DeleteMapping` handlers the way it stops a Boot write operation. That is\n  the gap `RestActuatorEndpointHandlerMapping.isMappingPermittedByAccessLevel` closes: it resolves\n  the endpoint's access through the `EndpointAccessResolver` bean and applies the same rules Boot\n  applies to operations, registering nothing under `NONE` and only `GET`/`HEAD`/`OPTIONS` mappings\n  under `READ_ONLY`. Note that `configureEndpointAccessToDenyUndefined` maps `READ_ONLY` to\n  `permitAll`, which is why the write methods used to get through, and maps `NONE` to `denyAll`, so\n  a `NONE` endpoint is usually refused by security before the missing mapping matters.\n  `ActuatorEndpointAccessControlTests` covers all of it, with `releaseAttributes` beside\n  `registeredServices` as the Boot operation endpoint that has always behaved this way.\n- Before changing what an access level does to these endpoints, measure the blast radius rather\n  than reasoning from the annotation: every REST actuator endpoint declares\n  `defaultAccess = Access.NONE` and the web application ships `management.endpoints.access.default=NONE`,\n  which suggests widespread breakage, but in fact all 44 test classes and all 69 puppeteer scenarios\n  that call one already declare `management.endpoint.<id>.access` or `management.endpoints.access.default`\n  explicitly. Walk the test tree and `ci/tests/puppeteer/scenarios/*/script.json` and count.\n- Do not confuse that with exposure. A REST actuator endpoint left out of\n  `management.endpoints.web.exposure.include` answers `403`, not `200`: no rule is registered for\n  it in `configureEndpointAccessToDenyUndefined`, and Spring Security denies a request no matcher\n  claims. Verify the status a given combination of exposure and access actually produces before\n  describing any of it as reachable.\n- When `management.server.port` differs, `CasCoreWebManagementContextConfiguration` registers the\n  handler mappings in the child management context but no `SecurityFilterChain`. Verify what\n  actually secures that port before assuming the main chain applies.\n- Several endpoints are authentication primitives, not diagnostics: `casValidate`,\n  `samlValidate` and `samlPostProfileResponse` mint CAS assertions and signed SAML responses\n  for an arbitrary username, and the `password` parameter is optional -- with no password they\n  fall through to `PrincipalResolver` and succeed. Anything that widens actuator access widens\n  impersonation. `tokenAuth` only validates a JWT and answers the principal with the service's\n  id, name and service id; it no longer issues tokens, and must never return service properties.\n- These endpoints return configuration values and service definitions unmasked, unlike Boot's own\n  `/env` and `/configprops`. `casConfig` `/retrieve` returns raw property values and\n  `registeredServices` serializes `OAuthRegisteredService.clientSecrets` (the `value` field carries\n  no `@JsonIgnore`). Masking was proposed and rejected, so do not re-propose it without agreeing\n  the approach with the maintainer first. `casConfig` `/encrypt` and `/decrypt` are a deliberate\n  operator tool that Palantir exposes in its UI, not an oracle to close.\n- Before proposing masking on any actuator response, work out how the admin UI consumes it: grep\n  `support/cas-server-support-thymeleaf/src/main/resources/static/js/palantir-*.js`. Palantir's\n  service editor does a full `GET /registeredServices/{id}` -> edit -> `PUT` round-trip\n  (`palantir-services.js`), so masking any path it reads would write the mask back into the\n  registry, and `/export` has the same requirement; redacting there needs the save path to preserve\n  existing secrets first. `casConfig` `/retrieve` does not have that problem, because\n  `palantir-pac4j.js` reads only `name` and `propertySource` from it.\n- User-supplied strings are compiled into regular expressions on the request path:\n  `configurationMetadata/{term}` (then matched over every known property in a `parallelStream`)\n  and `casConfig` `/retrieve`. Treat these as ReDoS/CPU-exhaustion boundaries; actuator endpoints\n  are not covered by CAS's authentication throttling.\n- No actuator endpoint carries `@Audit`. Session destruction, service deletion, key rotation,\n  configuration mutation and token minting all happen without an Inspektr record. Check for this\n  before claiming an administrative action is traceable.\n- Puppeteer coverage: `actuator-endpoint-cors`, `actuator-endpoint-login-jdbc`,\n  `actuator-endpoint-management`, `actuator-endpoint-metrics`, `actuator-endpoint-reports`,\n  `actuator-endpoint-roles`, `actuator-form-login`, `actuator-form-login-ldap`,\n  `actuator-form-login-ldap-roles`, `actuator-form-login-ldap-userauthz`,\n  `configuration-refresh-endpoint`, `sso-sessions-endpoint`, `tomcat-forwarded-headers`\n  (the only `IP_ADDRESS` scenario). Every one of them asserts that an *authorized* caller gets\n  200; none asserts 401/403 for an unauthenticated one, and 158 scenarios run with\n  `management.endpoints.web.exposure.include=*`. A change to endpoint security therefore needs a\n  new negative scenario rather than a rerun.\n\n<!-- File: .claude/skills/cas-attribute-consent/SKILL.md (Tokens: ~1972 | Category: anthropic-skill) -->\n---\nname: cas-attribute-consent\ndescription: \"Review and change attribute consent: ConsentDecision, the consent webflow gate, ConsentQueryResult, repositories and their lookups and deletes, decision decryption, dates and the consent scenarios. Use when touching support/cas-server-support-consent*.\"\n---\n\n# Attribute consent\n\n## Attribute consent review discipline\n\n- Start at `ConsentDecision`, not at a repository. `id` is a primitive `long` with\n  `@GeneratedValue`, so only JPA generates one. That single field is the key in four\n  backends: LDAP merges by it, Redis keys on `ConsentDecision:<principal>:<id>`, Mongo maps\n  it to `_id` by naming convention, and DynamoDB uses it as the table HASH key. It used to\n  stay `0` on every one of them, which collapsed each user (LDAP, Redis) or the whole\n  deployment (Mongo, DynamoDB) onto a single decision. `DefaultConsentDecisionBuilder.build`\n  now assigns a random positive id, JPA still replaces it with a generated one, and LDAP's\n  `mergeDecision` assigns one when `id <= 0`. Anything that creates a `ConsentDecision`\n  outside the builder -- the actuator's `/import`, say -- still arrives with id `0`.\n- `BaseConsentRepositoryTests` used to call `decision.setId(1/100/200)` before every store,\n  which is why the id defect never failed a test. `verifyMultipleDecisionsForPrincipal` now\n  stores builder-produced decisions for two users across two services and then deletes by id\n  and by principal, against every backend; `getOtherUser()` is overridden where the second\n  user must already exist (LDAP). The class holds a `@ResourceLock` because\n  `verifyDeleteRecordsForPrincipal` calls `deleteAll()` on the shared repository.\n- Consent is a gate in the login webflow, not a filter at attribute release.\n  `ConsentWebflowConfigurer` prepends `CheckConsentRequiredAction` to the login flow's\n  `generateServiceTicket` state; the only other consumer is\n  `SamlIdPConsentSingleSignOnParticipationStrategy`. Nothing in\n  `RegisteredServiceAttributeReleasePolicy` reads a stored decision, so the consented set\n  never constrains what is released, and any path that issues without the login flow --\n  REST protocol tickets, proxy tickets, OIDC refresh/userinfo, the OpenID4VCI\n  pre-authorized code grant -- releases attributes with no consent check at all.\n- Follow `ConsentQueryResult`. It carries the decision, the service and now the resolved\n  consentable attributes, and it used to be thrown away: `ConsentActivationStrategy` returned\n  a bare boolean and `CheckConsentRequiredAction` reduced it to an event, so\n  `prepareConsentForRequestContext` repeated the full `getConsentableAttributes` evaluation\n  (person directory included) and the repository read in the same request. The strategy now\n  returns the result and the action hands it to `prepareConsentForRequestContext`, which\n  resolves nothing when the result carries attributes -- a result built by a custom strategy\n  carries none and still falls back to resolving. What remains per consent interaction is one\n  evaluation and read for the prompt, one of each inside `storeConsentDecision` on confirm,\n  and one more from the `generateServiceTicketAfterConsent` clone that re-runs the check;\n  `getRegisteredServiceForConsent`, and with it the access strategy, still runs twice per\n  prompt. Count these by request before claiming a number.\n- `DefaultConsentEngine.executeRepositoryOperation` calls `toConsentRepository(tenant)` on\n  every operation, and `TenantJdbcConsentRepositoryBuilder` builds a DataSource plus a\n  Hibernate `EntityManagerFactory` inside it, then destroys them. Check the multitenant\n  path before judging consent performance; the single-tenant path is not representative.\n- Repository lookups must be checked for operator and pattern correctness, not just for\n  the right column names. `DynamoDbConsentFacilitator` matched SERVICE and ID with\n  `ComparisonOperator.GE` (correct for the date ranges other CAS DynamoDB facilitators\n  use it for, wrong for an equality lookup); it now uses `EQ`, but every read is still a\n  full `scanPaginator` because `id` is the table's only key. `RedisConsentRepository`\n  interpolated the raw principal into a SCAN glob; it now escapes glob characters and keeps\n  only keys whose remainder is a numeric id, because the `:` delimiter alone lets principal\n  `alice` reach the decisions of `alice:x`. Deleting one decision there is a `DEL` of the\n  exact key rather than a scan.\n- Deletes deserve the same reading as reads. `JpaConsentRepository.deleteConsentDecisions`\n  used `getSingleResult()` and so silently deleted nothing for any user with more than one\n  decision; it is a bulk JPQL delete now. `ChainingConsentRepository` -- which is what the\n  `consentRepository` bean always is, wrapping every configured store -- stored to every\n  repository but deleted with `anyMatch`, stopping at the first success; it now asks every\n  repository. `RestfulConsentRepository` sent `id` where the documented contract and the\n  test's own mock server say `decisionId`, so a conforming server read a single revocation\n  as \"revoke everything\". Revocation failing quietly is worse than consent failing loudly.\n- A decision that will not decipher used to be fatal: `getConsentableAttributesFrom` turned any\n  failure into `IllegalArgumentException`, which propagated out of `isConsentRequiredFor` and out\n  of the login flow, so key rotation or a node without the shared `cas.consent.core.crypto` keys\n  locked the user out. It now logs and returns no attributes, which reads as a mismatch and\n  re-prompts; the new decision is written back with the current keys. Weigh every failure on this\n  path the same way -- the login flow has no recovery for an exception thrown from the consent\n  check, so the question is always whether re-prompting is safe, not whether the error is correct.\n- `ConfirmConsentAction` parsed `option`, `reminder` and `reminderTimeUnit` straight off the request\n  with `Integer.parseInt` / `Long.parseLong` / `ChronoUnit.valueOf` and persisted them unvalidated, so\n  a missing parameter was a 500 on the confirm POST and `ChronoUnit.FOREVER` or `ERAS` parsed fine and\n  then threw from `createdDate.plus(...)` on every later login. Each value now falls back on the\n  configured default, and the pair is rejected unless it can actually be applied to a date -- checked by\n  applying it, since `LocalDateTime.isSupported` accepts `ERAS` and only the ISO era range rejects it.\n  `DefaultConsentEngine.calculateReminderExpirationDate` is the second half of that: a stored reminder\n  that cannot produce a date means consent is required, never an exception, which is what protects\n  decisions that were written before this validation existed or through the actuator's import.\n- `ConsentDecision.createdDate` is a zone-less `LocalDateTime` that one node writes and another compares\n  against its own clock, so it is produced with `ZoneOffset.UTC`, not `ZoneId.systemDefault()`. Before that,\n  the backends disagreed with each other: JPA, Redis, LDAP, DynamoDB and REST keep the wall-clock value\n  verbatim, so a node in another zone read a decision as hours older or newer, while MongoDB was accidentally\n  correct because Spring Data converts `LocalDateTime` through the system zone in both directions and so\n  normalizes to the instant. The reminder units offered in the consent view go down to seconds, so that skew\n  is not academic. `OneTimeToken.issuedDateTime` and the Google Authenticator token repositories still carry\n  the same pattern with second-scale windows.\n- `cas.consent.core.active` defaults to `true`, so adding the module turns consent on for\n  every service that releases attributes. Weigh findings about the consent path as if it\n  is always on.\n- Wallet/OID4VCI integration is the real gap, not a defect in the consent modules:\n  `BaseOidcVerifiableCredentialEncoder` reads `principal.getAttributes()` directly against\n  the issuer's configured claim list, so a credential handed to a wallet carries no\n  consent record, no per-claim choice, and no revocation. Selective disclosure exists in\n  the SD-JWT encoder but the holder never chooses what is disclosed at issuance time.\n- Puppeteer coverage: `attribute-consent` (in-memory repository, one user, one service),\n  `multitenancy-consent-jdbc`, `saml2-idp-login-consent`, `saml2-idp-login-consent-with-sso`.\n  No scenario exercises LDAP, Redis, Mongo, DynamoDB or REST consent, and none stores two\n  decisions for the same user, which is exactly the shape every storage defect above needs.\n\n<!-- File: .claude/skills/cas-build-ci/SKILL.md (Tokens: ~720 | Category: anthropic-skill) -->\n---\nname: cas-build-ci\ndescription: \"Gradle build settings (configuration cache, Isolated Projects, build cache, test-category discovery) and the GitHub Actions workflows (tests.yml coverage job, setup-gradle caching, daemon reuse, CodeQL). Use when changing build logic under buildSrc or gradle/, the root Gradle settings, or anything under .github/workflows or ci/.\"\n---\n\n# CAS build and CI workflows\n\n## Workflows that matter here\n\n- Gradle 9.8.0 enables build caching, the configuration cache, parallel execution and Isolated Projects here.\n  Isolated Projects configures every project on a cache miss and ignores configuration on demand. Compare representative\n  scoped commands before changing either setting. User-home `gradle.properties` overrides the repository's JVM settings.\n- Test category discovery runs inside `TestCategoryTagsValueSource`: only its sorted tag list is a configuration input.\n  Implementation edits retain the cached graph; adding/removing categories invalidates it. Do not move source reads back\n  into the convention script or restore the old `.gradle/cas-test-tags` fingerprint cache.\n- An IDE `JetGradlePlugin` error about `setExcludedTaskNames` comes from the IDE integration, not category discovery.\n  Use `--no-isolated-projects` for the affected IDE invocation until that integration supports Isolated Projects; keep\n  configuration caching enabled and do not disable Isolated Projects globally to hide an IDE-only violation.\n\n## Practical boundaries\n\n- Unit-test matrix jobs (`tests.yml`) record JaCoCo data only (`testcas.sh --with-coverage` only turns on the agent) and upload the `.exec` files; the single `coverage` job builds `jacocoRootReport` and does every Sonar/Codecov/Coveralls/Codacy upload. Do not move `jacocoRootReport` or `sonar` back into the matrix: the root report compiles all ~430 projects and Sonar re-analyses the whole code base, which is what made each category job 17 minutes instead of 7.\n- Every `gradle/actions/setup-gradle` step sets `gradle-home-cache-excludes: caches/build-cache-1` and\n  `cache-encryption-key: ${{ secrets.GRADLE_ENCRYPTION_KEY }}`. Preserve both: the local build cache duplicates Develocity\n  outputs, and the encryption key enables saving/restoring the project configuration cache. Fork PRs without this secret\n  can still build, but cannot restore the encrypted configuration cache. Do not layer `setup-java` or GraalVM Gradle\n  caching over `setup-gradle` in the same job.\n- CI permits Gradle daemon reuse within a job; `ci/init-build.sh` must not append a disabling user-home property.\n  Release, documentation and scenario scripts preserve daemon reuse across their Gradle invocations. CodeQL keeps\n  `--no-daemon` for compiler tracing. Test categories and Puppeteer scenarios are fetched once per discovery\n  job; print the captured result and split scenario JSON with `jq`, preserving the existing matrix boundaries.\n- The CodeQL job (`analysis.yml`) applies `--no-build-cache --rerun-tasks` after its shared build options so traced\n  compilation actually runs. The current `settings.gradle` has no CodeQL-specific `JavaCompile` cache guard. Preserve\n  these workflow flags: CodeQL only extracts compilations it observes, so restored or up-to-date outputs empty the scan.\n\n<!-- File: .claude/skills/cas-docs-site/SKILL.md (Tokens: ~3378 | Category: anthropic-skill) -->\n---\nname: cas-docs-site\ndescription: \"Edit the CAS documentation site (docs/cas-server-documentation, Jekyll plugins, site.js/site.css, ci/docs/publish.sh, the docs data generator) and release notes: page titles and descriptions, setting links, settings rules, release-notes front matter and tags, Quick Start wizard, sidebar, mobile layout. Use when editing any documentation page or release_notes/RC*.md.\"\n---\n\n# CAS documentation site\n\n## Practical boundaries\n\n- Documentation pages on gh-pages are pre-rendered per version, but they load the stylesheet and script from the shared site root, and every publish overwrites those root files. The current design ships as `stylesheets/site.css`, `stylesheets/site-print.css` and `javascripts/site.js`; the root `stylesheet.css`, `print.css` and `main.js` belong to the released versions' old markup. A redesign that changes page markup must use new asset names, never replace the ones older versions link.\n- The documentation data generator (`docs/cas-server-documentation-processor`) runs as `java @build/casdocsgen.args ...`, an argument file written by its `docsGeneratorArguments` task from `sourceSets.main.runtimeClasspath`; `publish.sh` no longer builds the ~1 GB `casdocsgen.jar` boot jar. Its Gradle run adds `-DskipErrorProneCompiler=true` (override with `DOCS_GENERATOR_GRADLE_OPTIONS`, an empty value restores Error Prone). It still needs every CAS module compiled: actuators, feature toggles and shell commands are found by ClassGraph scans of the runtime classpath, and third-party settings come from the dependencies' metadata. Those lookups go through `CasDocumentationClassIndex`, one ClassGraph scan of `org` shared by the exporters; add new class lookups there rather than calling `ReflectionUtils`, which scans the whole classpath on every call.\n- `ci/docs/publish.sh` exit codes carry meaning for the workflow retry (`retry_on_exit_code: 1`): 1 is a failure worth another attempt, 3 is broken internal links/images/scripts (fails at once, nothing is published), 4 is broken external links only (the site is published first, then the job fails). Keep new failure paths on 1 unless a rerun cannot help. External links are checked only on the weekly schedule or when asked (`--proof-external`, the `proofReadExternal` input or `vars.DOCS_PROOF_EXTERNAL`), with successful results cached for 7 days in `build/htmlproofer`.\n- Actuator endpoint blocks (`_includes_site/actuators.html`) take their operations from the `cas_actuator_operations` filter in `_plugins/cas_actuators.rb` (sorted, parameters normalized, curl built there), and settings snippets from `cas_actuator_enable_snippet` / `cas_actuator_security_snippet` via `cas-actuator-snippet.html` in all three formats. Per operation, render only what differs (parameters, response, example); setup, security, settings and troubleshooting are rendered once per include in the shared tabs. The operations table is excluded from `responsiveTables()`.\n- Settings search: `ci/docs/index.js` writes `assets/data/<version>/index.json` as `{generated, docs}` (no lunr index; each doc carries name, short type, description, default, kind required/optional/thirdparty, module, duration, deprecation). `site.js` searches it directly (`searchCasSettings`, shared by the Configuration Properties page and the Shift-Shift palette); the page state lives in the URL (`?q=&exact=1&scope=name&kind=cas|thirdparty&deprecated=hide`). The layout exposes `data-docs-base`, `data-docs-version` and `data-docs-build` on `<body>` for these URLs.\n- Feature toggles (`Configuration-Feature-Toggles.md`) render through `_includes_site/cas-feature-toggles.html` from the `cas_feature_catalog` filter in `_plugins/cas_features.rb`, which derives feature and module from the `CasFeatureModule.<Feature>[.<module>].enabled` property, groups features by area and links docs only when the target page exists for that version. Add title overrides, groups or docs links there, not in the include. The environment variable form maps both `.` and `-` to `_` (`CasFeatureEnabledCondition` reads through `Environment.getProperty`), unlike the relaxed-binding form used for regular settings.\n- Module dependency tabs (`_includes_site/casmodule.html`, included through `include_cached`) are plain Bootstrap tabs: no inline script, the default tab and pane are chosen together in Liquid, and pane ids use the full coordinates plus the variant options. Do not add per-include scripts or truncated ids; either one lets the selected tab and the visible pane drift apart.\n- Local serving (`publish.sh --serve true`) keeps the configured `baseurl: /cas`, so the site answers at `http://localhost:4000/cas/<version>/` exactly like production. `variables.html` always sets `basePath` to `/cas` and `site.js` has no localhost path branches; do not reintroduce `--baseurl \"\"` or localhost-specific paths.\n- Release notes (`release_notes/RC<n>.md`) carry a `release:` front matter block (summary, facts, upgrade notes, highlights, optional spotlight over a front matter image list) and open with `{% include release-digest.html %}` and end with `{% include release-footer.html %}`. Tag New & Noteworthy topics with a block IAL on the line after the heading and Other Stuff bullets with a list item IAL (`- {: .fixed} ...`): types are `new`, `changed`, `fixed`, `action`, `removed`; add `data-area=\"...\"` only when the first link does not identify the area. The area must be a key of `AREAS` in `_plugins/cas_release_notes.rb` (SSO cookies and sessions are `tickets`; there is no `sso`). `_plugins/cas_release_notes.rb` normalizes the tags, groups Other Stuff by area and resolves `section:` names to kramdown GFM heading ids before rendering; a `section:` that names no heading logs a warning. Upgrade notes may point at an Other Stuff area group by its label. `release_notes/RC.md` (front matter `release_aggregate:`) is generated from every RC page in the same folder: topics with the same title are merged and show each release candidate's notes, identical Other Stuff items are listed once with all their release candidates, and the `<!-- release-changes -->` marker is where the generated changes go. Never edit changes into that page by hand.\n- Section heading icons come from `CAS_SECTION_ICONS` in `site.js` and apply only to direct `h2` children of the article whose id is in that map; add an entry there rather than icons in markdown.\n- In the development docs, `site.js` wraps every table inside `#cas-docs-container` in a framed `.table-scroll` card, so do not use `<table>` for layout inside components (buttons, headers, badges); use flex markup instead.\n- Documentation property blocks get their settings from the `cas_properties` / `cas_third_party_properties` filters in `docs/cas-server-documentation/_plugins/cas_properties.rb`; do not loop over `site.data` in Liquid for this, it scans ~14k entries per block. Liquid `assign` inside an include writes to the page scope, so a `casproperties` include nested inside another block's panels must pass `topics=\"false\"` (and usually `intro=\"false\"`) or it resets the outer block's state. Their output is wrapped in `{::nomarkdown}`, so include them at the start of a line in markdown or pass the capture through `markdownify` (with `|`, not `||`). Catalog descriptions carry raw `<`/`>` (for example `management.endpoint.<id>.access`); the plugin escapes every tag outside `INLINE_TAGS` into `descriptionHtml` / `summaryText`, so print those fields and never unescape descriptions in Liquid: one stray tag inside the actuator modal makes kramdown drop the closing tags and everything after it on the page.\n- Documentation page titles must be unique: use `title: CAS - <page H1>`, and give overview pages a `description:`\n  front matter entry (under 160 characters). Pages without one get their meta description from the first paragraph in\n  `_layouts/default.html`, which skips dependency boilerplate, short or colon-ended lead-ins and \"see this guide\" lines.\n- The Versions menu lists `documentation_versions` from `docs/cas-server-documentation/_config.yml` (development plus\n  maintained releases); update it together with the EOL schedule in `developer/Maintenance-Policy.md`.\n- Third-party CSS and JS in `_layouts/default.html` are pinned to exact versions with `integrity` (sha384) and\n  `crossorigin=\"anonymous\"`. Recompute the hash whenever a version changes; Google Fonts and the gtag script cannot carry one.\n  Analytics is the GA4 tag `G-W3M8JXP7GP`, loaded only on `apereo.github.io`.\n- Do not dim muted text with `opacity`: `--docs-muted` on its own meets 4.5:1 in both themes, and opacity drops it below.\n  `--docs-amber` is darker in light mode for the same reason.\n- In `casmodule.html`, only the tab links sit inside `role=\"tablist\"`; the Resources dropdown is a sibling `<button>`.\n- Mobile layout: content must never run past the 1.25rem gutter. A flex or grid child that holds long inline `code` needs\n  `min-width: 0`, and the code needs `overflow-wrap: anywhere` (`break-word` does not lower the min-content width, so the\n  item still overflows). Bootstrap `.row` blocks in content lose their negative margins below 760px. To check, load pages in\n  375px iframes and look for elements whose right edge passes `#cas-docs-container` outside a scrolling ancestor.\n- Release notes pages carry `docs-release-notes` on `<body>` (set in `_layouts/default.html` from the page URL); their prose,\n  footer included, is justified and hyphenated, while stats, facts, the release switcher and inline code are left alone.\n- Docs sidebar: `sidebar.md` stays a plain nested list; `buildSidebarTree()` in `site.js` turns it into the grouped tree. A\n  section's group comes from `CAS_SIDEBAR_GROUPS` (by the section's `#anchor`), so add a new top-level section there too; one\n  left out lands in the last group. A parent whose first child is an `Overview` link becomes that link (the row is dropped);\n  other parents only toggle. Only the current path opens, siblings close as another opens, open rows are sticky at\n  `--sidebar-filter-h + depth × --sidebar-row-h`, and `.docs-sidebar` has no top padding so the filter can pin at the very top.\n- Settings in docs (required, standing rule from the maintainer): never refer to a configuration setting by name in\n  documentation pages, neither inline nor in fenced `properties` blocks. Describe the feature, say that it is available,\n  and say that it is enabled, disabled or tuned through CAS settings; the page's `casproperties` include lists them. The\n  only exception is release notes, which may name CAS or other settings.\n- Setting links in release notes (required): every setting named in release notes must be marked so readers can look\n  it up in place. Inline, write `` `cas.server.name`{: .cas-setting} ``\n  (an assignment such as `` `cas.tgc.secure=false`{: .cas-setting} `` works too; the value is ignored for the lookup). For a\n  fenced `properties` block of settings, put `{: .cas-settings-linked}` on the line right after the closing fence. Mark only\n  real CAS or Spring setting names, not prefixes used as headings, removed settings, JSON fields or registered-service\n  properties. `initializeCasSettingLinks()` in `site.js` turns these into buttons that call `openCasSettingsPalette(name)`:\n  an exact match opens the setting's details inside the Shift-Shift palette, a partial name shows the matches; the page\n  never changes. Linked blocks are rebuilt line by line from their text (key, separator, value), because Rouge's\n  properties lexer splits keys with `[0]` into separate tokens. `casSettingsFlat()` drops anything in brackets and any\n  purely numeric name segment, so `[0]`, `[]`, the catalog's map notation `.[key]`, `.0.` and the environment variable form\n  `_0_` all match the same setting. No catalog name has a numeric segment of its own; recheck that if one is ever added. `planning/Quick-Start.md` is the reference page.\n- Quick Start wizard (`planning/Quick-Start.md`, `javascripts/quick-start.js`): the page needs exactly eight `## N. ` steps;\n  anything else on the page is an `###` inside one of them, or it shows on every step. `data-quick-feature` takes a\n  space-separated list and shows the section when any listed feature is selected. Each checkbox's `data-module` must match\n  the `casmodule` include in its recipe (the fieldset's `data-modules` holds the always-included ones): step 2's\n  Initializr command and step 6's `dependencies` block are built from them, and step 6's `cas.properties` from every\n  visible `.cas-settings-linked` block in steps 3–5, in page order, under the nearest heading. Generated blocks are\n  plain fenced blocks inside `[data-quick-output]`; the script rewrites their text and gutter and calls\n  `linkCasSettingsBlock()` from `site.js` again. The selection is page state: popstate restores the step only, and a link\n  into a recipe selects its first feature with a notice. Checkboxes may declare `data-requires` and `data-excludes`\n  (space-separated feature values; Interrupt requires Groovy, Redis and Hazelcast exclude each other, declared on both\n  sides): checking selects requirements and clears conflicts, clearing a feature clears its dependents, Check All adds\n  only what fits, and a selection from the address is normalized with earlier features winning. Test the wizard in\n  headless Chromium against a rendered copy (Back/Forward, deep links, rules, Check All output) after changes.\n- Every documentation page needs an opening paragraph right after the H1 that the layout can use as its description:\n  at least 40 characters, not ending in a colon, and not a \"see this guide\" or dependency lead-in.\n- On phones (`max-width: 760px`) the sticky header is offset by `--docs-masthead-height` so only the navigation bar\n  stays visible, and `site.js` sets `--docs-header-height` to that bar alone; keep both in step when changing the masthead.\n- The docs load two web fonts only, Bricolage Grotesque (headings) and DM Sans (everything else); `--font-mono` is the system\n  monospace stack. Do not add font families or hotlink images from third-party hosts; keep images under `images/`.\n- Large topics are split into one page per concern with a sidebar submenu (see `authorization/Heimdall-Authorization-*.md`\n  and `multitenancy/Multitenancy-*.md`); when moving a section, update cross-page anchors and links in release notes.\n\n## FIDO2 WebAuthn / passkeys review discipline\n\n- JSON examples pasted into the documentation are pretty-printed (one member per line, two-space indent), never minified.\n\n<!-- File: .claude/skills/cas-google-authenticator/SKILL.md (Tokens: ~1269 | Category: anthropic-skill) -->\n---\nname: cas-google-authenticator\ndescription: \"Review and change Google Authenticator (GAuth) MFA credential repositories across backends (JPA, Redis, Mongo, DynamoDB, LDAP, JSON, REST, in-memory): owner-scoped deletes, encoding, counts, verification flags, the Redis index, crypto. Use when touching support/cas-server-support-gauth*.\"\n---\n\n# Google Authenticator MFA repositories\n\n## Google Authenticator credential repositories (all backends)\n\n- Review every backend against the same contract, with crypto on: `cas.authn.mfa.gauth.crypto.enabled` defaults to `true`, but every puppeteer scenario and the repository tests turn it off, so undecoded returns and double encryption never show up in CI.\n- A device delete must be scoped to its owner. WebAuthn, YubiKey and Duo device managers delete by username and id; `OneTimeTokenCredentialDeviceManager` and `GoogleAuthenticatorDeleteAccountAction` delete by id alone, and ids are guessable (JPA sequence; `currentTimeMillis` before 42bcde1).\n- `BaseOneTimeTokenCredentialRepository.encode()` mutates its argument. Never pass an object you will keep or save again; JPA `get(id)` and InMemory `get(id)` return stored, undecoded instances, and updating them double-encrypts.\n- `count()` means devices in Redis, JPA, Mongo and DynamoDB, but users in InMemory, JSON and LDAP; check which a caller assumes.\n- A repository read that fails must not look like \"no devices\": `OneTimeTokenAccountCheckRegistrationAction` sends empty or null results to enrollment (REST returns null, JSON returns empty on errors).\n- When testing deletes or updates, store devices for two users and two devices per user, and assert the others survive; single-user tests hid the DynamoDB `GE` filter and the LDAP update bug.\n- To make a range-filter bug visible in a DynamoDB delete test, pick keys that a wrong comparison would sweep up: the kept user's name sorts after the deleted one (\"z…\" vs \"a…\"), and the kept device ids are larger than the deleted id. Random UUIDs alone pass by luck half the time.\n- DynamoDB tables keyed by `id` should delete with `DeleteItem` on that key, not a scan followed by deletes.\n- Webflow actions and device managers that take a device id from the request must load it with the owner-scoped `get(principal.getId(), id)` and act only on what that returns. Unscoped `get(id)` / `delete(id)` are for the actuator and internal paths.\n- Every repository read returns a decoded copy (JPA detaches before decoding); `update()` must persist `properties`, which the verification flags of the delete and confirm actions live in.\n- The delete/confirm verification flags are stored on the account as `<flag>:<epoch millis>` and expire (`GoogleAuthenticatorAccountVerificationUtils`). They cannot live in flow or conversation scope: the login webflow keeps its execution state client-side, and the AJAX check and the form submit carry different execution keys.\n- `CasReentrantLock.tryLock(supplier)` returns null when the lock is not acquired; never let that null reach a caller as \"no accounts\".\n- `BaseOneTimeTokenCredentialRepository.encode()` returns an encoded copy and leaves its argument alone; store and return the copy, and read normalized values (lowercased username) from it, not from the argument.\n- Per-tenant ciphers must be built once and reused: a cipher built from settings with blank keys generates its own keys, so a new instance per call cannot decode what the previous one encoded. `BaseOneTimeTokenCredentialRepository` caches them by tenant and crypto settings.\n- WebAuthn FIDO metadata: `FidoMetadataDownloader.refreshBlob()` always downloads; `loadCachedBlob()` uses the cache until the BLOB's `nextUpdate`. CAS refreshes at startup and falls back to the cache on `IOException` (the FIDO service rate-limits with HTTP 429). `CompositeAttestationTrustSourceTests` downloads from the real service; a 429 there with no cache from an earlier run is an outage, not a regression.\n- Active Directory keeps a single value of `description` on user objects. The LDAP GAuth repository therefore writes all of an entry's accounts as one JSON array value; writing one value per account loses all but one device on AD.\n- A webflow action that validates a GAuth token and then updates the device must read the device again after validation: the validator updates its own copy (used scratch code, last-used time), and updating an older copy undoes that.\n\n## Google Authenticator Redis repository\n\n- Accounts live twice: the record at `CAS_TOKEN_ACCOUNT:<id>` and a copy as a member of the set `CAS_TOKEN_PRINCIPAL:<username>`. `get(username)`, `count(username)` and OTP validation read the set, not the record, so a delete that removes only the record leaves a device that is still listed and still validates.\n- Reads must not repair the index. A read that removes set members whose record \"does not exist\" acts on whatever the read saw; with `read-from` sending reads to replicas, and the record and the set on different cluster slots, a lagging replica makes a live device look orphaned and the removal on the primary is permanent. Repair belongs on the explicit `delete(id)`: when the record is missing the owner is unknown, so it scans `CAS_TOKEN_PRINCIPAL:*` in batches and removes members with that id.\n- Remove set members by the raw bytes Redis returned (`executePipelined(..., RedisSerializer.byteArray())` then `sRem`), not by re-serializing a deserialized account; the value serializer is LZ4 over JDK serialization, and a removal only matches if the bytes are identical.\n- Spring Data Redis pipelining works on Lettuce cluster connections (`LettuceClusterConnection` does not override `openPipeline`) and key `SCAN` spans all nodes, so pipelines and scans here are cluster-safe.\n\n<!-- File: .claude/skills/cas-groovy-scripting/SKILL.md (Tokens: ~654 | Category: anthropic-skill) -->\n---\nname: cas-groovy-scripting\ndescription: \"Review and change Groovy scripting support (core/cas-server-core-scripting): ScriptResourceCacheManager, CompiledGroovyScript, inline vs external scripts, compiling off the request path, script security. Use when touching Groovy scripts or any module that runs them.\"\n---\n\n# Groovy and scripting\n\n## Groovy and scripting\n\n- Start at `core/cas-server-core-scripting`; roughly fifty modules delegate to the same five classes, so\n  changes there are changes to authentication, attribute release, MFA, AUP, OIDC, SAML and themes at once.\n- `ScriptResourceCacheManager` is a singleton whose `close()` clears the entire cache — it must never be\n  used in a try-with-resources block.\n- `CompiledGroovyScript.of` decides how a script runs: a stateless script (only final fields holding immutable\n  values) gets a fresh instance per call and runs in parallel; a stateful one keeps a shared instance serialized\n  on a blocking lock, on purpose, so its fields stay consistent. Do not report that lock as a bottleneck. Review every script-backed\n  decision for what a `null` result means and prefer fail-closed defaults at security boundaries — MFA\n  triggers and AUP read `null` as \"skip\". Never guard script execution with a timed `tryLock`; a timeout\n  that returns `null` is indistinguishable from a script that declined.\n- Do not compile scripts on a request path (`fromScript`/`fromResource` per call). Go through\n  `resolveScriptableResource`, or hold the compiled script in a transient field, and remember `fromResource`\n  also starts a file-watcher thread that nothing closes. The cache manager comes from\n  `ApplicationContextProvider` and is absent outside Spring, so always fall back to the factory instead of\n  throwing, or you will break components and tests that are constructed directly.\n- Inline (`groovy { ... }`) and external (`file:`/`classpath:`) scripts behave differently: only external\n  scripts honour `failOnError` and support reload, and only inline scripts support bindings (the\n  `setBinding` interface default is a no-op). Inline scripts swallow `GroovyRuntimeException`. Test both\n  forms, and cover bindings with concurrent and pooled-thread tests — `GroovyShellScriptTests.BindingTests`\n  is the pattern.\n- Treat compiling a Groovy script as running it: AST transformations execute inside the compiler. Script\n  text that comes from anywhere other than deployment configuration must be parsed\n  (`ScriptingUtils.validateGroovyScript`), never compiled.\n- Puppeteer coverage lives in the 18 `ci/tests/puppeteer/scenarios/*groovy*` scenarios\n  (`service-access-strategy-groovy`, `surrogate-login-groovy`, `mfa-provider-selection-trigger-groovy`,\n  `interrupt-afterauthn-groovy`, `webflow-groovy-action`, …). None of them exercise concurrency, script\n  errors, or cache expiry, so a change in this area needs new coverage rather than a rerun.\n\n<!-- File: .claude/skills/cas-handoff/SKILL.md (Tokens: ~689 | Category: anthropic-skill) -->\n---\nname: cas-handoff\ndescription: \"Finish and hand off a change in the CAS repository: Checkstyle on changed Java files, no stale .git/index.lock, release-notes and puppeteer cross-checks, and the ready-to-use commit message. Use at the end of every task that changed files in cas-server, before reporting the work as done.\"\n---\n\n# Handing off CAS work\n\nRun these steps, in order, before reporting the work as done. The rules behind them follow.\n\n1. Compile what changed, main and test sources, with Error Prone, each changed module on its own class path so a\n   missing test dependency shows (see Conventions in AGENTS.md, the `cas-testing` skill and, in a sandbox without\n   Gradle, the `cas-sandbox-verification` skill).\n2. Run Checkstyle on every changed Java file (rule below), and fix overloads, imports and file length it reports.\n3. Run `git --no-optional-locks diff --check`.\n4. Cross-check the puppeteer scenarios that cover the change and update them (`cas-puppeteer-scenarios` skill).\n5. Add the release-notes entry when the work calls for one (security notes: see \"Test, concurrency, and hand-off\n   expectations\" in AGENTS.md; release-notes format: `cas-docs-site` skill).\n6. Record new, durable lessons in AGENTS.md or in the matching skill under `.claude/skills/`.\n7. Check `ls .git/*.lock` and clear what is left.\n8. End with the commit message (rule below). Say clearly what was not run.\n\n## Test, concurrency, and hand-off expectations\n\n- When the work is done, end the hand-off with a ready-to-use commit message covering only the uncommitted changes\n  (`git --no-optional-locks status`): a conventional subject (`fix:`, `feat:`, ... as in `git log`) and a short body.\n  The maintainer commits; do not commit yourself.\n\n## Environment limits\n\n- Leave no `.git/index.lock` behind. `git status` and `git diff` take that lock to refresh the index, and in a remote environment that cannot delete files the lock survives the command and blocks every subsequent git operation the maintainer runs. Read git state with `git --no-optional-locks status` / `git --no-optional-locks diff`, which never takes it, and before finishing check `ls .git/*.lock` and clear anything left. If deletion is refused, request it rather than leaving the repository wedged.\n- Maintainer: run Checkstyle on every changed Java file before handing work off, even without Gradle. Use the version in\n  `gradle/libs.versions.toml` (`checkstyle-<version>-all.jar` from the Checkstyle GitHub releases; 14.x needs Java 21+) as\n  `java -Dcheckstyle.suppressions.file=style/checkstyle-suppressions.xml -Dcheckstyle.importcontrol.file=style/import-control.xml\n  -jar checkstyle.jar -c style/checkstyle-rules.xml <files>`. When the workspace JDK is older, tar the files and `style/` into\n  `build/` (deletable without asking) and run it where Java 21 is available.\n\n<!-- File: .claude/skills/cas-heimdall-authzen/SKILL.md (Tokens: ~2016 | Category: anthropic-skill) -->\n---\nname: cas-heimdall-authzen\ndescription: \"Review and change Heimdall authorization and its AuthZEN PDP endpoints (support/cas-server-support-heimdall*): request parsing, caller authentication, policies (JDBC, JSON resources), evaluations, PDP metadata, throttling and the heimdall-authzen/heimdall-nginx scenarios. Use when touching Heimdall code, docs or scenarios.\"\n---\n\n# Heimdall and AuthZEN\n\n## Heimdall / AuthZEN review discipline\n\n- Check the latest OpenID Authorization API final specification and editor's draft. AuthZEN is a PEP/PDP\n  protocol; wallet exchange uses OID4VCI/OID4VP. Treat a verified-presentation-to-policy bridge as a feature.\n- Trace `DefaultAuthorizationPrincipalParser` through `DefaultAuthorizationEngine` and each policy.\n  A valid caller token does not establish PDP audience, PEP permissions or DPoP proof possession;\n  delegated subject ids are legitimate for trusted PEPs and are not inherently impersonation.\n- An AuthZEN `resource.id` names the resource instance, never a policy namespace. AuthZEN requests\n  (`AuthorizationRequest.isAuthZen()`: subject, resource and action all present) match `resourceType`, `actions` and an\n  optional full-match `resourceIdPattern` across every namespace, and every match must grant. Normal requests\n  keep namespace + URI pattern + method and `/heimdall/authorize` rejects AuthZEN fields, which keeps the paths apart.\n- Callers authenticate with tokens issued to a registered OAuth/OIDC service whose access strategy allows\n  access; `HeimdallRegisteredServiceAccessStrategy` (alone or chained) can refuse it. DPoP and `x509_digest`\n  certificate bindings are enforced. `x509_digest` is the RFC 8705 `x5t#S256` thumbprint (base64url SHA-256 of the\n  DER certificate); compute it only with `OAuth20Utils.computeCertificateThumbprint` so issuer and verifiers agree. On AuthZEN, Basic means `client_id:client_secret` (reject clients without secrets:\n  `DefaultOAuth20ClientSecretValidator.validate` returns true when none is defined); on `/heimdall/authorize` it\n  stays CAS user credentials, split on the first colon. Both endpoints are throttled; `HeimdallThrottledHandlerInterceptor`\n  forwards only 401s to the throttle interceptors' post-processing. A `ThrottledRequestFilter` cannot limit what\n  counts: the default `httpPost()` filter claims every POST and the plan combines filters with `anyMatch`, and the\n  interceptors record every non-2xx response (twice: `postHandle` and `afterCompletion`; harmless for decisions,\n  since stores keep the latest failure per key or read the authentication audit trail).\n  Create registry-backed single-use markers through the `TicketFactory` (TST factory, custom `ExpirationPolicy` in\n  a *mutable* properties map: `buildExpirationPolicy` removes that key, so `Map.of` throws), never by instantiating\n  ticket implementations.\n- macOS JDKs use a polling `WatchService` (2s): a file created and deleted between two polls produces no event.\n  Watcher-driven caches must reconcile against the file system on every event rather than trust event kinds. JWT assertions need `jti`/`iat`, are single-use,\n  and share the token endpoint's audiences on purpose. A resource without policies denies.\n- JDBC trusted parameters (`principal`, `method`, `uri`, `namespace` and the AuthZEN names) are added after\n  context and attributes, so they already win; an overwrite claim there was a false finding. Check that policy\n  file deletion removes cached grants. `enforceAllPolicies=false` means any one policy grants: a failing policy is\n  logged and skipped, and failures are rethrown only when nothing grants (never turn them into a silent deny).\n  `enforceAllPolicies=true` stops at the first denial or failure. An empty policy list denies before either runs.\n- JDBC policies resolve their `DataSource` by bean name (`dataSourceName`, else `heimdallJdbcDataSource-<sha256(url|username)>`)\n  and register a `JpaBeans.newPoolingDataSource` pool (Hikari defaults, `minimumIdle=0`) through\n  `GenericApplicationContext.registerBean` under a lock, so Spring closes it on shutdown. Never cache a pool on a\n  policy instance (policies are rebuilt on every file reload) and never use `registerSingleton` for closeable beans:\n  Spring gives registered singletons no destruction callbacks. The pool key ignores the password (a rotated password\n  needs a new `dataSourceName` or a restart; documented). `queryTimeout` (default `PT5S`) is set on the `JdbcTemplate`. Tests override `resolveApplicationContext()` with a\n  local context rather than relying on the static `ApplicationContextProvider`, which parallel tests share.\n- `JsonAuthorizableResourceRepository` publishes one immutable `ResourceIndex` (by namespace and by AuthZEN\n  `resourceType`) per reload; AuthZEN lookups use the type index, so keep both maps in the same snapshot.\n- Policies evaluate sequentially; do not reintroduce `parallelStream()` (blocking policies starve `commonPool`).\n- AuthZEN subjects are resolved through the principal resolver only for subject type `user` (hardcoded);\n  other types become a bare principal. Policies read request data through\n  `AuthorizationRequest.resolveAttributeValues` (qualified `subject.*`, `resource.*`, `action.*`, `context.*` names);\n  never merge caller-supplied properties into principal attributes, which would let a PEP override the directory.\n  AuthZEN `context` is body-only; `/heimdall/authorize` adds non-protocol headers with `putIfAbsent`.\n- Palantir rebuilds resources from a fixed field list (`heimdallResourceForStorage`) and re-saves the whole\n  namespace; any new `AuthorizableResource` field must be added there or an edit silently drops it.\n- AuthZEN PDP metadata: the identifier is `<prefix>/heimdall`, served at `/cas/heimdall/.well-known/authzen-configuration`\n  (`HeimdallAuthZenConfigurationController`). The spec inserts the well-known segment after the host\n  (`/.well-known/authzen-configuration/cas/heimdall`), outside the CAS context: deployments need a proxy or ENGINE\n  rewrite-valve rule, as in the `heimdall-authzen` scenario's `rewrite.config`. `policy_decision_point` must equal the\n  identifier the PEP started from. List only endpoints that exist (no search yet).\n- AuthZEN decision context: denials carry `context.reason` from `AuthorizationDecisionReason` (set by the engine:\n  `subject_unresolved`, `no_matching_resource`, `no_policies`, `policy_denied`); grants carry no context. Never put\n  policy names, attribute names or messages in it; details go to DEBUG logs. Search (R5) and the OID4VP wallet bridge\n  (R7) are deferred and noted as future work in the Heimdall and verifiable credentials docs.\n- AuthZEN evaluations (`/heimdall/authzen/evaluations`): top-level subject/resource/action/context are defaults that an\n  entry replaces per key. Authenticate the caller once (`authenticateCaller`), then `resolveSubject` per entry;\n  never call `parse` per entry (it would consume single-use JWT assertions). Per-entry failures are `decision:false` with\n  `context.error.{status,message}` (no exception text for 500s); short-circuit semantics omit the remaining entries;\n  no/empty `evaluations` falls back to the single evaluation. Evaluations run sequentially.\n- AuthZEN evaluated denials use HTTP 200 with `decision:false`; authentication failures use 401.\n  Discovery/batch/search are separate capabilities, and the specification's example endpoint path is not mandatory.\n- Read shared helpers before reporting leaks: request headers already filter credentials and the request\n  principal is JSON-ignored. Confirm performance severity with evidence; blocking policy parallel streams\n  and unpooled JDBC merit investigation, not an unmeasured claim of outage.\n- Puppeteer scenario `heimdall-authzen` covers the AuthZEN endpoint end to end (decisions across namespaces,\n  id patterns, deny-wins, empty policies, `X-Request-ID`, unknown fields, 400/401, the Heimdall access strategy,\n  client-credential and bearer PEPs, the legacy endpoint, the actuator and throttling). It also enables throttling,\n  so keep 401-producing steps at least one throttle window apart. Palantir scenarios only check that tabs load.\n- Scenario `heimdall-nginx` drives the shared nginx config (`ci/tests/nginx`) as a PEP: `/api` uses `auth_request`\n  against `/heimdall/authorize`. The subrequest must set `proxy_method POST` (it is a GET otherwise) and send a\n  `namespace`; nginx does not escape variables in `proxy_set_body`, so URIs with `\"` or `\\` are rejected by a `map`.\n  `auth_request` maps any status other than 2xx/401/403 (such as Heimdall's 404) to 500. Validate edits with a local\n  `nginx -t` and stub upstreams; the config resolves `host.docker.internal` at load time.\n\n<!-- File: .claude/skills/cas-import-pr/SKILL.md (Tokens: ~282 | Category: anthropic-skill) -->\n---\nname: cas-import-pr\ndescription: \"Bring an apereo/cas GitHub pull request onto a local branch without a git identity, keeping the contributor as author, by fetching each non-merge commit as a patch and applying it with git am. Use when asked to review, fix up or test a contributor PR locally.\"\n---\n\n# Importing a contributor pull request\n\n1. List the PR's commits (`https://api.github.com/repos/apereo/cas/pulls/<n>/commits`) and drop the\n   \"Merge branch 'master'\" commits.\n2. Cut a local branch from master.\n3. For each remaining commit, oldest first, fetch the patch and apply it as described below.\n4. Leave the result uncommitted beyond the contributor's own commits; the maintainer commits review fixes himself\n   (`cas-handoff` skill).\n\n## Environment limits\n\n- The remote shell has no git identity. To bring a contributor's PR onto a local branch, fetch each non-merge commit as a patch from `https://api.github.com/repos/apereo/cas/commits/<sha>` with `Accept: application/vnd.github.patch` and apply it with `git -c user.name=... -c user.email=... am --3way`, which keeps the contributor as author. Skip the \"Merge branch 'master'\" commits; the local branch is cut from master already.\n\n<!-- File: .claude/skills/cas-interrupt-notifications/SKILL.md (Tokens: ~397 | Category: anthropic-skill) -->\n---\nname: cas-interrupt-notifications\ndescription: \"Review and change interrupt notifications: InterruptWebflowConfigurer, inquirers, the tracking cookie, passive requests, SSO reuse by protocol modules. Use when touching support/cas-server-support-interrupt*.\"\n---\n\n# Interrupt notifications\n\n## Interrupt notifications\n\n- Review the whole webflow path in `InterruptWebflowConfigurer`, not a single action: inquiry is wired at several\n  states, and the request-scoped finalized marker decides whether later checkpoints re-run the inquirers.\n- Treat the tracking cookie as a user-held acknowledgement. Changes to finalize/tracking must keep blocked\n  responses unacknowledgeable and must consider which principal the cookie was issued for.\n- Puppeteer interrupt scenarios often log out between logins, which removes the tracking cookie; a regression step for tracking must log in again without logging out.\n- Passive requests (CAS `gateway`, OIDC `prompt=none`, SAML `IsPassive`) must not render the interrupt view. When adding an event to an action that is prepended to an existing action state, add the matching transition to that state; an unmatched event silently continues to the next action.\n- A check that only runs in the login webflow is bypassed wherever a protocol module reuses the SSO session directly (for example the SAML2 IdP). For interrupts this is accepted for now; do not close it by vetoing SSO participation from the interrupt module without agreeing on the approach with the maintainer.\n- Inquirer errors currently fail open; any fix must state the intended behavior for blocking policies.\n- Reloadable inquirer state (files, watchers) must be published atomically; a failed reload keeps the last good contents rather than failing open.\n- Payloads map onto `InterruptResponse`, whose no-arg constructor means \"interrupt\". Parse external responses only after the status check, and test error bodies against random-port `MockWebServer` instances.\n\n<!-- File: .claude/skills/cas-ldap/SKILL.md (Tokens: ~463 | Category: anthropic-skill) -->\n---\nname: cas-ldap\ndescription: \"Review and change LDAP integration: LdapUtils, connection factories and pools, search result handling, password transport, fail-open authorization, logging of directory data. Use when touching LDAP authentication, attribute repositories, password management or any LDAP-backed module.\"\n---\n\n# LDAP\n\n## LDAP review discipline\n\n- Start at the shared seam. `LdapUtils` builds connection configs, filters, authenticators, DN/entry resolvers\n  and person-attribute DAOs for every LDAP-backed feature; `LdapConnectionFactory` wraps every search, modify,\n  add, delete and password-modify. Findings there generalize; findings in one feature module usually do not.\n- Follow the whole flow: property model -> `LdapUtils` construction -> auto-configuration bean -> the calling\n  repository or handler -> the webflow/endpoint boundary that consumes the result. Most real defects live in\n  the mismatch between two of those layers, not inside one class.\n- Search results are a trust boundary. Check the LDAP result code, not just whether an entry came back, and\n  check what happens on truncation (server size/time/admin limits), on referral or continuation references,\n  and on multi-valued attributes that directories return in ranges.\n- Connection factories are pooled and stateful. Confirm they are created once as beans, that a pool used for\n  user binds is passivated back to its original bind state, and that maps of factories are keyed by something\n  that actually distinguishes two configurations.\n- For any code that reads or writes a password, verify the transport requirement against the current RFC text\n  and confirm the guard is enforcement rather than a log line; read the boolean carefully for inverted sense.\n- Treat a fail-open authorization branch as critical regardless of how it is reached. Trace the default value\n  of every property the branch depends on, including Spring Boot defaults CAS does not override.\n- Watch for values that are written by one path and compared by another, and for filter/template parameters\n  that are silently ignored when the operator's template does not reference them.\n- Secrets and directory internals must not reach logs or released attributes: password-reset answers, bind\n  credentials, diagnostic messages and matched DNs all deserve a second look.\n\n<!-- File: .claude/skills/cas-mcp-server/SKILL.md (Tokens: ~2628 | Category: anthropic-skill) -->\n---\nname: cas-mcp-server\ndescription: \"Review and extend the CAS MCP server (support/cas-server-support-mcp): @McpTool beans, security and auditing, optional tool groups, typed results and output schemas, secret redaction, resources and completion, tests and the mcp-server-* scenarios. Use when adding or changing MCP tools, resources or prompts.\"\n---\n\n# CAS MCP server module\n\n## MCP server module (`support/cas-server-support-mcp`)\n\n- Tools are plain beans whose methods carry `@McpTool` plus `@PreAuthorize(\"hasRole('MCP_ADMIN')\")`;\n  `CasMcpToolsTests.verifyToolsAreSecuredByProxies` asserts every such bean is an AOP proxy. Read tools need `MCP_ADMIN`.\n- Tools that change CAS (`AI.mcp-write`, off by default) live in separate `*AdministrationTools` beans so the toggle can drop\n  them: core ones in `CasAdministrationTools` (sessions with SLO through `SingleLogoutRequestExecutor`, throttle release via\n  `ThrottledSubmissionsStore.removeIf`, `setServiceEnabled` on `DefaultRegisteredServiceAccessStrategy` including chained\n  ones, `setLogLevel` through Spring Boot's `LoggingSystem`), optional ones as extra `@Bean` methods in the group's nested\n  configuration, or a nested configuration of their own (`CasMcpOAuthAdministrationConfiguration`). Each bean method carries\n  `@ConditionalOnFeatureEnabled(module = \"mcp-write\", enabledByDefault = false)`, each tool `hasRole('MCP_OPERATOR')`,\n  `readOnlyHint = false` and `destructiveHint = true` unless harmless. They accept session references and record ids from\n  the read tools, never raw TGT or token ids. Tests: `CasMcpToolsTests` turns `mcp-write` on (bean level, `MCP_OPERATOR`\n  mock user, Mockito `verify` on the mocked MFA repositories), `CasMcpServerTests.WriteToolsTests` (MockMvc, `mcpoperator`,\n  tokens), and `ProtocolTests` asserts they are not listed by default. Checkstyle rejects `public LoggerXxx` and a parameter\n  named `logger` (non-static logger rule).\n- A tool over an optional feature follows the consent pattern: `compileOnly` (and `testImplementation`) on that module, a\n  nested `@Configuration` with `@ConditionalOnClass` and feature module `mcp-<group>`, an `ObjectProvider` for the backing\n  bean and a `reason` in the result when it is absent; test activation in `CasMcpAutoConfigurationTests`.\n- Never return secrets: device fingerprints, trusted-device record keys, client secrets, key material, setting values.\n  Tests assert the secret string is absent from the result.\n- A turned-off events repository is a `BeanSupplier` proxy, not null: check `BeanSupplier.isProxy`.\n- Every `@McpTool`/`@McpResource` call is audited by one infrastructure advisor (`CasMcpAuditInterceptor`, ordered before\n  method security so denials are recorded) that hands a `CasMcpOperation` (an `AuditableEntity`, principal = the MCP\n  user) to the `@Audit`-annotated `CasMcpAuditor`. New tools are covered automatically; never add `@Audit` to tool\n  methods (the default audit principal resolver would take the first argument, the person being looked up).\n- Tools summarize; they never return serialized CAS objects (tickets, services' secrets) or attribute values outside\n  findPerson/previewAttributeRelease. Ticket-granting ticket ids are bearer tokens: return\n  `CasMcpSessionReferences.of(id)` and accept references back through `CasMcpSessionReferences.resolve` (with the\n  username). Service JSON from findService/matchService goes through the secret redaction in `CasServiceManagementTools`.\n- MCP capabilities are set in `webapp/cas-server-webapp-resources/src/main/resources/application.properties`\n  (`spring.ai.mcp.server.capabilities.*`) and mirrored in `BaseCasMcpTests`; a `@McpResource` or `@McpPrompt` bean is\n  silently not advertised while its capability is false. Resources (`org.apereo.cas.mcp.resources`) return JSON built\n  with a plain `JsonMapper`: CAS's `JacksonObjectMapperFactory` mappers use `NON_DEFAULT` inclusion and drop `false`/`0`.\n- `DefaultAuthenticationRiskEvaluator` is `@Transactional` on `transactionManagerEvents`; test contexts that use a real\n  evaluator need that bean (`PseudoTransactionManager`, as `CasCoreEventsAutoConfiguration` registers it).\n- Tools that evaluate a person at an application build their subject with `CasMcpEvaluationContextFactory.build`\n  (service, matching registered service, resolved principal, `authentication()`); request-aware components get\n  `currentHttpExchange()`, the live request with parameters, headers, cookies and query string hidden, or\n  `newWebflowRequestContext(context)`, a webflow request context over it with service, registered service, credential\n  and authentication in scope. Do not resolve services or principals by hand in a tool. Tools that need client details\n  (IP address, user agent, location) take them as parameters and wrap them in `CasMcpClient`: `ClientInfoHolder`\n  describes the MCP operator. `currentHttpExchange(client)` exposes them as remote address, `User-Agent` and the\n  `geolocation` parameter; `withClientInfo(client, ...)` swaps `ClientInfoHolder` only around components that read it\n  (MFA triggers). Do not swap it around audited calls such as the risk evaluator, or the audit trail records the\n  supplied address instead of the operator's.\n- A new tool also goes into `CAS_TOOLS` (`CasMcpServerTests`, `mcp-server-tools/script.js` when core), the role-denial\n  lists, `FeatureDisabledTests`, and the tables and feature-toggle list in `Admin-MCP-Server.md`. A new optional group also\n  goes into `CasDeploymentTools.MCP_TOOL_GROUPS` (feature module to bean name), which `describeDeployment` reports.\n- Tools return typed records with `@McpTool(generateOutputSchema = true)` (only findService/matchService return free-form\n  JSON strings): tools/list then carries an `outputSchema` and calls return `structuredContent`, which the MCP SDK validates\n  against the schema and copies into the text content. Mark optional components `@Nullable` (jspecify) and annotate the\n  record `@JsonInclude(NON_NULL)`: non-null components are `required` in the schema and a `null` fails validation. Never\n  return `null` from a typed tool; return the record with a `reason`. Keep JSON names stable (puppeteer reads them), use\n  `String` for dates, `Object` for nested CAS objects whose shape should not become schema, `@JsonProperty` for Java\n  keywords such as `default`, and Lombok `@Builder` for records with many optional components. Bean-level tests use the\n  record accessors.\n- A tool that combines others (explainLogin) calls the other tool beans and converts their records to maps with a plain\n  `JsonMapper`. `CasMcpAuditInterceptor` binds a `ScopedValue` for the outermost call, so nested tool calls are not audited.\n- Puppeteer builds CAS with `-DskipNestedConfigMetadataGen=true`, so the settings catalog at runtime holds only third-party\n  (Spring) settings and no `cas.*` ones. Scenarios exercise settings tools with a Spring setting such as\n  `spring.ai.mcp.server.name`; `cas.*` settings and the `module` filter are covered by unit tests (`McpSettingsMetadata.json`).\n- MCP completion (`@McpComplete`) only completes resource-template and prompt arguments, never tool parameters. Completion\n  methods carry `@PreAuthorize` like tools and are not audited; `spring.ai.mcp.server.capabilities.completion` must be true.\n- Claude's shells have no JDK 25 or Maven Central; `build/mcp-run.sh` compiles, runs checkstyle, the `RestfulApi` tests\n  and the MCP puppeteer scenarios on the Mac.\n- OAuth resource checks use `OAuth20RequestParameterResolver.resolveAllowedResources(service)`, which adds the CAS-wide\n  `cas.authn.oauth.core.allowed-resources` (default: the CAS MCP endpoint) to the service's `allowedResources`; never\n  match against `getAllowedResources()` alone. `AbstractOAuth20Tests` and `BaseCasMcpTests` allow `https://cas.example.org/cas/mcp`.\n- Bearer access tokens (`AI.mcp-oauth`, nested `CasMcpOAuthConfiguration`, needs OIDC for the issuer): the filter\n  `CasMcpBearerTokenAuthenticationFilter` is built inside a `CasWebSecurityConfigurer`, never exposed as a bean (Spring Boot\n  registers `Filter` beans for every request). It sits before `BasicAuthenticationFilter`, acts only on the MCP path with a\n  `Bearer` header, sets a `CasMcpBearerTokenAuthentication` (principal id, client id, scopes) without saving it, and grants\n  `ROLE_MCP_ADMIN` only from `cas.mcp.oauth.required-attributes` (empty matches nobody), and `ROLE_MCP_OPERATOR` only with the\n  `mcp:write` scope plus `cas.mcp.oauth.operator-required-attributes` (`grantRoles`), checked against attributes resolved\n  through `defaultPrincipalResolver` (token principal as current principal): a token's principal carries only the attributes\n  released to the client, so `memberOf` and the like are usually missing there. The unauthenticated challenge comes from\n  `defaultAuthenticationEntryPointFor(..., mcp matcher)`, registered before `httpBasic` initializes so it wins. Being first\n  also makes it Spring Security's fallback for requests no entry point claims (browser requests to actuators), so outside\n  the MCP path it answers with the Basic challenge alone. A configurer must never set an explicit `authenticationEntryPoint`,\n  which replaces every other one; Palantir's CAS login registers its entry point with `defaultAuthenticationEntryPointFor`\n  for `/palantir/**` only (`DashboardControllerTests.CasAuthenticationTests`). The security context is saved in a request attribute only (async SSE dispatches need\n  it), never in the HTTP session.\n- Spring Security's Basic challenge is `Basic realm=\"Realm\", charset=\"UTF-8\"` (MockMvc reports it as more than one value), so\n  `CasMcpAuthenticationEntryPoint` delegates to `BasicAuthenticationEntryPoint` rather than writing it, and tests compare with\n  that entry point's output (`CasMcpServerTests.basicChallenge`). `jsonPath(...).value(List.of(...))` reads `null` because the\n  expected value's class (`List.of`'s immutable type) becomes the target type; assert `[0]` and `length()` instead. Protected resource metadata lives under the CAS\n  context (`/cas/.well-known/oauth-protected-resource/mcp`), because the root well-known path is outside the web app; clients\n  reach it through `resource_metadata`. Tests build `OAuth20DefaultAccessToken` directly and add it to the registry\n  (`CasMcpServerTests.newAccessToken`); `mcp-server-saml-oidc` gets a JWT token through the password grant.\n- `CasMcpEvaluationContextFactory` hands the username to the principal resolvers as the current principal. Without attribute repositories the chain holds only `EchoingPrincipalResolver`, which echoes the current principal, so resolving without one yields `NullPrincipal` (`nobody`).\n- OAuth/OIDC services serialize their secret as `clientSecrets` (a list of objects with `value`); `getClientSecret()` is `@JsonIgnore`. Redaction of service definitions must cover the plural property, which `SECRET_PROPERTY` in `CasServiceManagementTools` does with `secrets?`.\n- `CasOidcTools.checkOidcClientResource` reuses `RegisteredServiceOAuthResourceMatcher` (`isValidResource`,\n  `findAllowedResource`) so the tool and the protocol endpoints decide resource indicators the same way; new tools are\n  added to `CasMcpServerTests.CAS_TOOLS` and to the optional-tool lists of the `mcp-server-*` scenarios.\n\n<!-- File: .claude/skills/cas-oauth-oidc-authentication/SKILL.md (Tokens: ~300 | Category: anthropic-skill) -->\n# cas-oauth-oidc-authentication\n\nPath: `.claude/skills/cas-oauth-oidc-authentication/SKILL.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/apereo/cas/HEAD/.claude/skills/cas-oauth-oidc-authentication/SKILL.md)\n\n<!-- File: .claude/skills/cas-oauth-oidc/SKILL.md (Tokens: ~300 | Category: anthropic-skill) -->\n# cas-oauth-oidc\n\nPath: `.claude/skills/cas-oauth-oidc/SKILL.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/apereo/cas/HEAD/.claude/skills/cas-oauth-oidc/SKILL.md)\n\n<!-- File: .claude/skills/cas-oid4vc/SKILL.md (Tokens: ~300 | Category: anthropic-skill) -->\n# cas-oid4vc\n\nPath: `.claude/skills/cas-oid4vc/SKILL.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/apereo/cas/HEAD/.claude/skills/cas-oid4vc/SKILL.md)\n\n<!-- File: .claude/skills/cas-passwordless-webauthn/SKILL.md (Tokens: ~300 | Category: anthropic-skill) -->\n# cas-passwordless-webauthn\n\nPath: `.claude/skills/cas-passwordless-webauthn/SKILL.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/apereo/cas/HEAD/.claude/skills/cas-passwordless-webauthn/SKILL.md)\n\n<!-- File: .claude/skills/cas-protocol/SKILL.md (Tokens: ~300 | Category: anthropic-skill) -->\n# cas-protocol\n\nPath: `.claude/skills/cas-protocol/SKILL.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/apereo/cas/HEAD/.claude/skills/cas-protocol/SKILL.md)\n\n<!-- File: .claude/skills/cas-puppeteer-scenarios/SKILL.md (Tokens: ~300 | Category: anthropic-skill) -->\n# cas-puppeteer-scenarios\n\nPath: `.claude/skills/cas-puppeteer-scenarios/SKILL.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/apereo/cas/HEAD/.claude/skills/cas-puppeteer-scenarios/SKILL.md)\n\n<!-- File: .claude/skills/cas-saml/SKILL.md (Tokens: ~300 | Category: anthropic-skill) -->\n# cas-saml\n\nPath: `.claude/skills/cas-saml/SKILL.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/apereo/cas/HEAD/.claude/skills/cas-saml/SKILL.md)\n\n<!-- File: .claude/skills/cas-sandbox-verification/SKILL.md (Tokens: ~300 | Category: anthropic-skill) -->\n# cas-sandbox-verification\n\nPath: `.claude/skills/cas-sandbox-verification/SKILL.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/apereo/cas/HEAD/.claude/skills/cas-sandbox-verification/SKILL.md)\n\n<!-- File: .claude/skills/cas-service-registries/SKILL.md (Tokens: ~300 | Category: anthropic-skill) -->\n# cas-service-registries\n\nPath: `.claude/skills/cas-service-registries/SKILL.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/apereo/cas/HEAD/.claude/skills/cas-service-registries/SKILL.md)\n\n<!-- File: .claude/skills/cas-session-stores/SKILL.md (Tokens: ~300 | Category: anthropic-skill) -->\n# cas-session-stores\n\nPath: `.claude/skills/cas-session-stores/SKILL.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/apereo/cas/HEAD/.claude/skills/cas-session-stores/SKILL.md)\n\n<!-- File: .claude/skills/cas-startup-performance/SKILL.md (Tokens: ~300 | Category: anthropic-skill) -->\n# cas-startup-performance\n\nPath: `.claude/skills/cas-startup-performance/SKILL.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/apereo/cas/HEAD/.claude/skills/cas-startup-performance/SKILL.md)\n\n<!-- File: .claude/skills/cas-storage-backends/SKILL.md (Tokens: ~300 | Category: anthropic-skill) -->\n# cas-storage-backends\n\nPath: `.claude/skills/cas-storage-backends/SKILL.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/apereo/cas/HEAD/.claude/skills/cas-storage-backends/SKILL.md)\n\n<!-- File: .claude/skills/cas-testing/SKILL.md (Tokens: ~300 | Category: anthropic-skill) -->\n# cas-testing\n\nPath: `.claude/skills/cas-testing/SKILL.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/apereo/cas/HEAD/.claude/skills/cas-testing/SKILL.md)\n\n<!-- File: .claude/skills/cas-ticket-registries/SKILL.md (Tokens: ~300 | Category: anthropic-skill) -->\n# cas-ticket-registries\n\nPath: `.claude/skills/cas-ticket-registries/SKILL.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/apereo/cas/HEAD/.claude/skills/cas-ticket-registries/SKILL.md)\n\n<!-- File: .claude/skills/cas-ui/SKILL.md (Tokens: ~300 | Category: anthropic-skill) -->\n# cas-ui\n\nPath: `.claude/skills/cas-ui/SKILL.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/apereo/cas/HEAD/.claude/skills/cas-ui/SKILL.md)\n\n<!-- File: .claude/skills/cas-web-security/SKILL.md (Tokens: ~300 | Category: anthropic-skill) -->\n# cas-web-security\n\nPath: `.claude/skills/cas-web-security/SKILL.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/apereo/cas/HEAD/.claude/skills/cas-web-security/SKILL.md)\n\n<!-- File: CLAUDE.md (Tokens: ~300 | Category: claude-rule) -->\n# Claude Agent Guidelines & System Prompt\n\nPath: `CLAUDE.md`\n\n[View Raw Content on GitHub](https://raw.githubusercontent.com/apereo/cas/HEAD/CLAUDE.md)\n\n<!-- ... +2 more modular skills available via /api/skills/apereo/cas?path={path} -->\n</yakaai_skills>"}