JanssenProject / JanssenProject/jans
feat(jans-fido2): propagate end-user IP and user agent to FIDO2 so metrics describe the user, not the calling service
- Dominant language
- Java
- Stars
- 647
- Forks
- 173
- Avg merge
- 1d 18h
- Merged PRs (30d)
- 110
Description
**Is your feature request related to a problem? Please describe.**
Every `ipAddress`, `userAgent`, `browser`, `os` and `deviceType` value in `jansFido2MetricsEntry` describes the Auth Server, not the end user. Sample rows from `admin-ui-test.gluu.org`:
| Field | Recorded value | Should be |
| --- | --- | --- |
| `ipAddress` | the VM's own address | the browser's public IP |
| `userAgent` | `Apache-HttpClient/4.5.14 (Java/17.0.20)` | the real browser UA |
| `browser` / `os` | `Unknown` / `Unknown` | e.g. `Safari` / `macOS` |
| `deviceType` | `DESKTOP` on every row | `MOBILE` / `DESKTOP` as applicable |
The cause is architectural: the browser never talks to the FIDO2 server. It calls the Auth Server, which relays to `/attestation/*` and `/assertion/*` through `Fido2ClientFactory` — a plain RESTEasy/Apache HTTP client with no header propagation (`jans-fido2/client/.../Fido2ClientFactory.java:77-88`). The proxy interfaces it builds declare no header parameters at all (`AssertionService.java:24-38`, `AttestationService.java`). So by the time a request reaches `MetricService`, the only client it can observe is the Auth Server itself.
`MetricService.extractIpAddress` (`MetricService.java:730-771`) does the right thing for a browser-facing service — it checks `X-Forwarded-For` plus eight other proxy headers before falling back to `getRemoteAddr()`. Nothing sets them here, because the caller is a service-to-service client, not a reverse proxy.
The `deviceType` case is worse than merely missing. `DeviceInfoExtractor.determineDeviceType` (`DeviceInfoExtractor.java:171-181`) matches `mobile`/`android`/`iphone` → `MOBILE`, `tablet`/`ipad` → `TABLET`, and **everything else falls through to `DESKTOP`**. There is no unknown branch. So every service-to-service call is silently recorded as a desktop, and the resulting chart looks plausible rather than empty — the failure mode is fabricated data, not absent data.
Downstream consumers of these fields are therefore wrong today: `deviceTypes`, `browsers` and `operatingSystems` in `/metrics/analytics` (`Fido2MetricsService.java:437-470`), `DEVICE_TYPES` in the aggregation rollup (`:758-764`), and the device/IP panels in the Flex Admin UI Passkey Security Monitor (GluuFederation/flex#2905). The "Suspicious IPs" graph had to be dropped from that dashboard because the IP column carries no usable signal.
Not affected, and safe to keep graphing: usernames, statuses, counts, durations, and `authenticatorType` (platform / cross-platform) — the last of these is read from the credential itself (`AttestationService.java:323-344`, `AssertionService.java:423`), not from the user agent.
**Describe the solution you'd like**
Pass the end user's connection context across the Auth Server → FIDO2 boundary and prefer it when present.
1. Add dedicated headers to the client proxy interfaces — `X-Jans-Client-IP` and `X-Jans-Client-User-Agent` — as `@HeaderParam` arguments on `AssertionService.authenticate/verify` and the `AttestationService` equivalents, with `Fido2ClientFactory` unchanged.
2. In `MetricService.extractIpAddress` and `DeviceInfoExtractor.extractDeviceInfo`, prefer the new headers over `X-Forwarded-For`/`getRemoteAddr()`/`User-Agent`, and only trust them from a configured set of callers — the spoofing caveat already documented at `MetricService.java:717-726` applies with more force once a header is authoritative.
3. Independently of the above, change `determineDeviceType` to return `UNKNOWN` when the user agent matches no known pattern, instead of defaulting to `DESKTOP`. Absence should read as absence. This is worth doing on its own even if the propagation design changes, since it converts a wrong chart into an honestly empty one.
Backwards compatibility: headers are optional, so existing callers keep working and simply continue producing the current values.
**Describe alternatives you've considered**
- **Do nothing and drop the affected graphs.** This is what the Admin UI did as a stopgap — Suspicious IPs was replaced with a per-user attack graph. It works, but it permanently forfeits geo-anomaly and impossible-travel detection, which are the main security value of passkey metrics.
- **Have the browser POST device context straight to FIDO2.** Requires a new unauthenticated public endpoint plus CORS, and the payload would be entirely client-controlled — strictly worse than a trusted server-side hop.
- **Look the context up from the session.** FIDO2 already receives `sessionId`, and the Auth Server has recorded the IP and UA against that session. No new headers, and not spoofable by the client. Rejected as the primary approach because it couples FIDO2 to Auth Server session storage and yields nothing for direct API callers, but it is a reasonable fallback for the cases where no header is supplied.
- **Reuse `X-Forwarded-For` rather than a dedicated header.** Zero server-side change, since `extractIpAddress` already reads it. Rejected because it conflates a genuine proxy hop with an application-level relay, and would make a real reverse-proxy deployment ambiguous.
**Additional context**
- Same trust boundary as [#14744](https://github.com/JanssenProject/jans/issues/14744), and the two should be designed together. That issue needs the browser's error name and elapsed time to reach FIDO2, and rejects a direct browser→FIDO2 call in favour of relaying through the Authorization Server; this issue needs the browser's IP and user agent to reach FIDO2 over the same hop. Both are "client context the FIDO2 server structurally cannot observe", and a single decision on how the Auth Server forwards that context should settle both. They differ in one respect worth keeping straight: [#14744](https://github.com/JanssenProject/jans/issues/14744) relays a report the client volunteers after a ceremony ends, so it is untrusted annotation, whereas the IP and user agent here are observed by the Auth Server on the live request and are as trustworthy as the Auth Server's own view of the client.
- Found by @arnab-dutta while validating the Passkey Security Monitor dashboard (GluuFederation/flex#2905) against live data on `admin-ui-test.gluu.org`.
- Until this lands, treat `ipAddress` and the device/browser/OS breakdowns as unusable, and note that `deviceType` is actively misleading rather than blank.
- Needs a design call on header naming and the trusted-caller list before implementation.
- Scope note: this spans `jans-fido2` (the client proxy interfaces, `MetricService`, `DeviceInfoExtractor`) and the callers that hold the browser's request — the person-authentication script, Casa, and `jans-auth-server`'s `passkeys.xhtml`. Step 4 (`determineDeviceType` returning `UNKNOWN` instead of defaulting to `DESKTOP`) is self-contained inside `jans-fido2` and needs none of the above design decision; it can be split out and landed first if that is preferable.
Contributor guide
Assessment
This issue has not been assessed yet.