Status
Frozen design proposal. Not currently scheduled for implementation. Filed here so the technical investigation is visible and we don't lose context between sessions. Action will follow once an end-to-end auth handshake is validated against a real Entra-joined target (Sol1 has a test box ready).
Goal
Support the equivalent of the Windows mstsc "Use a web account to sign in to the remote computer" toggle. This lets users RDP into Entra-joined Windows hosts using their Entra identity (with MFA / Conditional Access / passkey, as configured in the tenant) instead of a username and password.
First-iteration scope is intentionally narrow:
- One Entra tenant per rustguac install (config value).
- MFA and Conditional Access are respected, because the auth happens through real Microsoft endpoints, not screen-scraping.
- No new app registration required in the customer tenant (see "Discovery" below).
Why not the upstream PR
apache/guacamole-server#633 (GUACAMOLE-2210) by @aleitner is the obvious candidate. It adds `security=aad` to guacd by screen-scraping Microsoft's HTML login pages with libcurl: fetches the page, parses the `$Config` JS object for session tokens, POSTs the user's password, follows the redirect, extracts the auth code, exchanges for token, feeds to FreeRDP.
This approach is unacceptable for our use cases because:
- Fragile. Microsoft can break the auth path with any UI change. The PR's own comments document this happening during review (May 2026: "credential POST field name needs to be flowToken (camelCase) per $Config.sFTName, and for AVD sessions Microsoft now serves a /common/rdp/set device-binding page...").
- No MFA. It can't handle Microsoft's MFA challenges, Conditional Access policies, passwordless (FIDO2, Windows Hello), or any modern auth flow. Plain username/password only.
- Defeats the purpose. Most shops enabling Entra-for-RDP did so specifically to require MFA / CA. This PR doesn't let them do that.
Maintainer review on the PR also has `CHANGES_REQUESTED` status with no recent merge activity.
Proposed architecture (Path 1: callback through guacd)
Reading FreeRDP 3.15's `libfreerdp/core/aad.c` shows the AAD path uses proof-of-possession (PoP) binding: FreeRDP generates an RSA 2048 keypair per connection, requests a token that's cryptographically bound to that key, then signs a JWS during NLA. There is no pre-supplied-bearer-token path for the direct-to-Entra-joined-host flow; the only entry point is the `GetAccessToken` callback that FreeRDP fires mid-handshake.
We supply that callback through a guacd patch and broker tokens from rustguac:
```
User clicks Connect on Entra-flagged RDP entry
|
v
rustguac sends `security=aad` connection args to guacd
|
v
guacd RDP module: sets FreeRDP_AadSecurity=TRUE
installs custom GetAccessToken callback
|
v
FreeRDP: generates RSA 2048 PoP keypair
computes scope = ms-device-service://termsrv.wvd.microsoft.com/name//user_impersonation
fires callback with (scope, kid)
|
v
guacd callback: reads PoP pubkey JWK from a new FreeRDP accessor
sends `argv aad-token <scope+pubkey-jwk>` to rustguac
calls guac_argv_await() to block
|
v
rustguac: WebSocket-proxy intercepts argv with name 'aad-token'
uses user's OIDC refresh token to call
POST /oauth2/v2.0/token with grant_type=refresh_token,
scope=, req_cnf=<base64url(pubkey JWK)>
gets PoP-bound access token from Microsoft
responds with `argv aad-token `
|
v
guacd callback unblocks, returns token to FreeRDP
|
v
FreeRDP signs JWS with PoP private key, completes NLA against Windows host
```
Patch surface
FreeRDP patch (~20 lines). The rdpAad struct is opaque and `GetAccessToken` only receives the JWK thumbprint, not the full key. We need a new accessor `freerdp_aad_get_pop_pubkey_jwk(rdpContext*)` so the callback can request a PoP-bound token. Doesn't change the existing ABI; only adds an export. Worth proposing upstream.
guacd patch (~200 lines). Adds `security=aad` mode in `src/protocols/rdp/settings.c`, installs the custom `GetAccessToken` callback in the RDP pre-connect path, plumbs through to rustguac via `guac_argv_register`/`guac_argv_await`. Lives in `patches/` alongside our existing FreeRDP-3 patches.
rustguac changes:
- New `[entra]` config section: `tenant_id`, optional `redirect_uri` override. No client_id - we use Microsoft's well-known public client ID `a85cf173-4192-42f8-81fa-777a763e6e2c` (the official Microsoft Remote Desktop app), which is the same client ID FreeRDP impersonates. This client owns the RDS scope; a customer-owned app registration cannot mint tokens for `ms-device-service://termsrv.wvd.microsoft.com/...`.
- Per-RDP-entry `use_entra_auth: bool` flag in the Vault entry schema and the connections UI.
- Device-code flow on first connect (or on refresh-token expiry): rustguac POSTs to `/devicecode` with the well-known client ID, shows the user a code + verification URL, user completes Entra auth with whatever MFA/CA the tenant requires, rustguac polls until tokens arrive. Refresh token stored server-side.
- WebSocket proxy hook intercepts `argv aad-token` from guacd, does the PoP-bound token exchange, responds with the token. Never forwards to the browser; never logs the token.
- Error paths: refresh expired or CA challenge required -> bounce user back through device-code re-auth with a clear message.
Discovery / what we confirmed
Test executed 2026-05-14 from a test box against an internal Entra-joined Windows VM (confirmed working with mstsc + the "use a web account" toggle on Windows):
```
wlfreerdp3 /v: /sec:aad /cert:ignore /log-level:DEBUG
```
Negotiated `RDSAAD` security successfully with the target; FreeRDP printed the exact OAuth URL it would normally have the user follow:
```
https://login.microsoftonline.com/common/oauth2/v2.0/authorize
?client_id=a85cf173-4192-42f8-81fa-777a763e6e2c
&response_type=code
&scope=ms-device-service://termsrv.wvd.microsoft.com/name//user_impersonation
&redirect_uri=ms-appx-web://Microsoft.AAD.BrokerPlugin/a85cf173-4192-42f8-81fa-777a763e6e2c
```
Key takeaways:
- The RDSAAD path is fully functional against direct Entra-joined Windows hosts (not just AVD).
- Microsoft's well-known mstsc client ID is what FreeRDP uses; rustguac uses it too, no new app registration.
- The scope is dynamic per target host (embeds the short hostname).
- The redirect URI is the Windows broker scheme (`ms-appx-web://`), unusable from a server. We use device code flow instead.
- Debian 13's `libfreerdp3-3` (3.15.0+dfsg-2.1) was built with `WITH_AAD=ON` already. We don't need to rebuild FreeRDP just to install the integration; only the small pubkey accessor patch is required.
Known unknowns
- Exact form of Microsoft's `req_cnf` parameter for direct-to-host (not AVD) AAD. Derivable from FreeRDP source, will be confirmed by the manual-paste handshake test that's the next concrete action.
- Behaviour of FreeRDP issue #9870 (intermittent `ERRCONNECT_ACTIVATION_TIMEOUT` post-AAD) against our target. Workaround if hit is to narrow transport (`/no-udp`).
Implementation order (when unfrozen)
- Complete the manual paste handshake from Sol1 office against the Sol1-internal test box. Confirms the upstream auth flow end-to-end and gives us a real `req_cnf` exchange to model. Blocker for everything below.
- FreeRDP patch: `freerdp_aad_get_pop_pubkey_jwk` accessor.
- guacd patch: `security=aad` mode + argv-bridged `GetAccessToken` callback.
- Manual end-to-end test through patched guacd with a hand-crafted argv response (mock rustguac).
- rustguac `[entra]` config + device-code flow + refresh token storage.
- rustguac argv handler + Microsoft `/token` endpoint call with `req_cnf`.
- Per-entry `use_entra_auth` flag + UI in connections editor.
- Deploy to sol1-remoteconsole, validate against the Sol1-internal test box.
- Ship in v1.7.x or v1.8.x as a real feature (this is multiple weeks of work, not bugfix scope).
Out of scope for first iteration
- Multi-tenant fleets (one tenant per rustguac install).
- WebAuthn/FIDO2 virtual channel (FreeRDP #8730) for in-session passkey use. Different protocol channel; separate work.
- Token caching across sessions for the same user. First version mints per-connect.
- Browser-side MSAL.js / popup flows. Device code is sufficient and works on every UA.
References
- Project memory: `memory/project_entra_rdp.md` (full design with code citations and resume point)
- FreeRDP AAD code: `libfreerdp/core/aad.c` (3.15.0)
- Zenn article on FreeRDP + sso-mib broker: https://zenn.dev/yskszk63/articles/freerdp-with-aad
- FreeRDP issue #9870: AAD activation timeout (known bug to watch)
- Apache PR #633: the screen-scraping approach we are explicitly not taking
Status
Frozen design proposal. Not currently scheduled for implementation. Filed here so the technical investigation is visible and we don't lose context between sessions. Action will follow once an end-to-end auth handshake is validated against a real Entra-joined target (Sol1 has a test box ready).
Goal
Support the equivalent of the Windows mstsc "Use a web account to sign in to the remote computer" toggle. This lets users RDP into Entra-joined Windows hosts using their Entra identity (with MFA / Conditional Access / passkey, as configured in the tenant) instead of a username and password.
First-iteration scope is intentionally narrow:
Why not the upstream PR
apache/guacamole-server#633 (GUACAMOLE-2210) by @aleitner is the obvious candidate. It adds `security=aad` to guacd by screen-scraping Microsoft's HTML login pages with libcurl: fetches the page, parses the `$Config` JS object for session tokens, POSTs the user's password, follows the redirect, extracts the auth code, exchanges for token, feeds to FreeRDP.
This approach is unacceptable for our use cases because:
Maintainer review on the PR also has `CHANGES_REQUESTED` status with no recent merge activity.
Proposed architecture (Path 1: callback through guacd)
Reading FreeRDP 3.15's `libfreerdp/core/aad.c` shows the AAD path uses proof-of-possession (PoP) binding: FreeRDP generates an RSA 2048 keypair per connection, requests a token that's cryptographically bound to that key, then signs a JWS during NLA. There is no pre-supplied-bearer-token path for the direct-to-Entra-joined-host flow; the only entry point is the `GetAccessToken` callback that FreeRDP fires mid-handshake.
We supply that callback through a guacd patch and broker tokens from rustguac:
```
User clicks Connect on Entra-flagged RDP entry
|
v
rustguac sends `security=aad` connection args to guacd
|
v
guacd RDP module: sets FreeRDP_AadSecurity=TRUE
installs custom GetAccessToken callback
|
v
FreeRDP: generates RSA 2048 PoP keypair
computes scope = ms-device-service://termsrv.wvd.microsoft.com/name//user_impersonation
fires callback with (scope, kid)
|
v
guacd callback: reads PoP pubkey JWK from a new FreeRDP accessor
sends `argv aad-token <scope+pubkey-jwk>` to rustguac
calls guac_argv_await() to block
|
v
rustguac: WebSocket-proxy intercepts argv with name 'aad-token'
uses user's OIDC refresh token to call
POST /oauth2/v2.0/token with grant_type=refresh_token,
scope=, req_cnf=<base64url(pubkey JWK)>
gets PoP-bound access token from Microsoft
responds with `argv aad-token `
|
v
guacd callback unblocks, returns token to FreeRDP
|
v
FreeRDP signs JWS with PoP private key, completes NLA against Windows host
```
Patch surface
FreeRDP patch (~20 lines). The rdpAad struct is opaque and `GetAccessToken` only receives the JWK thumbprint, not the full key. We need a new accessor `freerdp_aad_get_pop_pubkey_jwk(rdpContext*)` so the callback can request a PoP-bound token. Doesn't change the existing ABI; only adds an export. Worth proposing upstream.
guacd patch (~200 lines). Adds `security=aad` mode in `src/protocols/rdp/settings.c`, installs the custom `GetAccessToken` callback in the RDP pre-connect path, plumbs through to rustguac via `guac_argv_register`/`guac_argv_await`. Lives in `patches/` alongside our existing FreeRDP-3 patches.
rustguac changes:
Discovery / what we confirmed
Test executed 2026-05-14 from a test box against an internal Entra-joined Windows VM (confirmed working with mstsc + the "use a web account" toggle on Windows):
```
wlfreerdp3 /v: /sec:aad /cert:ignore /log-level:DEBUG
```
Negotiated `RDSAAD` security successfully with the target; FreeRDP printed the exact OAuth URL it would normally have the user follow:
```
https://login.microsoftonline.com/common/oauth2/v2.0/authorize
?client_id=a85cf173-4192-42f8-81fa-777a763e6e2c
&response_type=code
&scope=ms-device-service://termsrv.wvd.microsoft.com/name//user_impersonation
&redirect_uri=ms-appx-web://Microsoft.AAD.BrokerPlugin/a85cf173-4192-42f8-81fa-777a763e6e2c
```
Key takeaways:
Known unknowns
Implementation order (when unfrozen)
Out of scope for first iteration
References