Skip to content

Conversation

@ravicious
Copy link
Member

@ravicious ravicious commented Nov 25, 2025

In the Device Trust service, we want to be able to differentiate between iPadOS and macOS. This is so that when we add Device Trust for mobile devices and someone adds just a macOS device to their trusted devices, they won't be prompted for Device Trust on iPadOS and vice versa.

However, since iPadOS 13, it's impossible to tell it apart from macOS based on the user agent alone since it started using the same user agent as macOS. To work around this, people typically use navigator.maxTouchPoints to detect if the device supports touch controls. If yes, then we can assume the user is on an iPad.

To pass this information to the auth service, I modified the frontend app to send navigator.maxTouchPoints through the Max-Touch-Points header. It seemed more fitting to send some additional metadata this way rather than through the payloads of specific endpoints. The user agent is sent in a header too after all.

The next question was which endpoints should receive Max-Touch-Points. The user agent is currently read from the header in four different lib/web endpoints (clientMetaFromReq reads the header):

  • mfaLoginFinishSession - finishes MFA ceremony and returns a web session.
  • mfaLoginFinish – like above, but returns an SSH cert instead.
  • createWebSession – creates a new web session when using TOTP.
  • headlessLogin – used for headless logins, returns an SSH cert.

From these four, only two can result in a Device Trust prompt being shown: mfaLoginFinishSession and createWebSession. As such, while clientMetaFromReq always attempts to read the header, it'll be sent from the frontend app only to those two endpoints.

Minor refactorings

To enable this, I had to change two small things done in the first two commits.

First, Connect was constructing the device web token message from protobufs by hand. This meant that each time a new field was added, it had to be added by hand in this one place with a default value, otherwise it wouldn't pass the type checker. I refactored code in Connect to use DeviceWebToken.create which creates a new message with default values. I also removed an unnecessary layer from the code, opting to use the gRPC client directly (ClustersService used to be the only place with access to the gRPC client, but it hasn't been the case for a long time now).

Next, the Web UI has two functions for sending POST requests, post and postFormData, and none of them supported sending custom headers. Nic had a similar problem with DELETE requests, where we had delete, then deleteWithHeaders and then we needed both headers and some extra custom arguments and we finally ended up with deleteWithOptions. Similarly, instead of adding a third function called postWithHeaders I added postWithOptions which accepts regular data, form data, and headers.

There's already post and postFormData. Similar to deleteWithOptions,
instead of adding just another positional arg with custom headers to
either post or postFormData, I've decided to create a third function
with a more flexible API.

We could deprecate the other two functions but I don't know when I'll
backport this change to v18 yet. The post function should be enough for
95% of cases.
@ravicious ravicious added the no-changelog Indicates that a PR does not require a changelog entry label Nov 25, 2025
}
var receivedWebToken *devicepb.DeviceWebToken
authServer.SetCreateDeviceWebTokenFunc(func(ctx context.Context, dwt *devicepb.DeviceWebToken) (*devicepb.DeviceWebToken, error) {
receivedWebToken = dwt
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How bad is this? receivedWebToken is shared between subtests but for now there is only a single test.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What if we changed the function to return nil, nil maxTouchPoints > 0 and added an iPadOS test case? I believe that is how it would work in practice, since we don't support registration of iPad devices.

@ravicious ravicious marked this pull request as ready for review November 25, 2025 17:04
@ravicious ravicious requested review from kimlisa and removed request for gzdunek and ryanclark November 25, 2025 17:04
Copy link
Contributor

@codingllama codingllama left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed Go parts.

}
var receivedWebToken *devicepb.DeviceWebToken
authServer.SetCreateDeviceWebTokenFunc(func(ctx context.Context, dwt *devicepb.DeviceWebToken) (*devicepb.DeviceWebToken, error) {
receivedWebToken = dwt
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What if we changed the function to return nil, nil maxTouchPoints > 0 and added an iPadOS test case? I believe that is how it would work in practice, since we don't support registration of iPad devices.

return res, trace.Wrap(err)
}

const headerMaxTouchPoints = "Max-Touch-Points"
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

My 2c: I would move this into "clientMetaFromReq" and just hard-code the header name in the test. Or hard-code the header name in both places. Headers are more a case of "well-known string" than "magic string".

@codingllama codingllama changed the title Pass nagivator.maxTouchPoints from Web UI to auth service Pass navigator.maxTouchPoints from Web UI to auth service Nov 25, 2025
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As such, while clientMetaFromReq always attempts to read the header, it'll be sent from the frontend app only to those two endpoints.

Is it wise to cherry-pick the endpoints? I'm concerned this lead to a "misunderstanding" bug, because looking at the Go sources it seems like the header is always there.

Maybe add a comment to ForwardedClientMetadata.MaxTouchPoints saying it's only available in select endpoints?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

no-changelog Indicates that a PR does not require a changelog entry size/md ui

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants