From 2e32fceedaaed876c1728455f9ce05926838cee8 Mon Sep 17 00:00:00 2001 From: ravicious Date: Tue, 2 Dec 2025 11:07:18 +0100 Subject: [PATCH] Pass `navigator.maxTouchPoints` from Web UI to auth service (#61777) * DocumentAuthorizeWebSession: Call tshd client directly * Add api.postWithOptions for sending POST with custom 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. * Send Max-Touch-Points header to endpoints which lead to Device Trust prompt * Pass max touch points to auth service * Add iOS and iPadOS as new OSType values * Hardcode header name * Mention availability of `ForwardedClientMetadata.MaxTouchPoints` * Remove new OSes from OSType for now * Remove leftover logger * Change fake UA in tests to be more Surface-like instead of iPad-like * Add separate TestCreateWebSession subtest for iPadOS --- .../devicetrust/v1/device_web_token.pb.go | 22 ++++- .../devicetrust/v1/device_web_token.proto | 7 ++ .../devicetrust/v1/device_web_token_pb.ts | 20 +++- lib/auth/authclient/clt.go | 8 ++ lib/auth/methods.go | 3 + lib/auth/sessions.go | 17 +++- lib/auth/sessions_test.go | 95 +++++++++++++------ lib/web/apiserver.go | 15 ++- lib/web/apiserver_test.go | 6 +- .../teleport/src/services/api/api.test.ts | 86 +++++++++++++++++ web/packages/teleport/src/services/api/api.ts | 66 ++++++++++++- .../teleport/src/services/auth/auth.test.ts | 24 +++-- .../teleport/src/services/auth/auth.ts | 23 ++++- .../DocumentAuthorizeWebSession.story.tsx | 2 +- .../DocumentAuthorizeWebSession.tsx | 26 ++--- .../ui/services/clusters/clustersService.ts | 25 ----- 16 files changed, 351 insertions(+), 94 deletions(-) diff --git a/api/gen/proto/go/teleport/devicetrust/v1/device_web_token.pb.go b/api/gen/proto/go/teleport/devicetrust/v1/device_web_token.pb.go index 4ead5b0a652..8d1f7970955 100644 --- a/api/gen/proto/go/teleport/devicetrust/v1/device_web_token.pb.go +++ b/api/gen/proto/go/teleport/devicetrust/v1/device_web_token.pb.go @@ -72,8 +72,14 @@ type DeviceWebToken struct { // Used internally by the Device Trust system. // Transient. ExpectedDeviceIds []string `protobuf:"bytes,7,rep,name=expected_device_ids,json=expectedDeviceIds,proto3" json:"expected_device_ids,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + // BrowserMaxTouchPoints indicates whether the client device supports touch controls. It is sent + // by the frontend app to the proxy service and then forwarded to the auth service. It + // differentiates iPadOS from macOS since they both use the same user agent otherwise. This + // information is needed to decide whether to show the Device Trust prompt in the Web UI after a + // successful login. + BrowserMaxTouchPoints uint32 `protobuf:"varint,8,opt,name=browser_max_touch_points,json=browserMaxTouchPoints,proto3" json:"browser_max_touch_points,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *DeviceWebToken) Reset() { @@ -155,11 +161,18 @@ func (x *DeviceWebToken) GetExpectedDeviceIds() []string { return nil } +func (x *DeviceWebToken) GetBrowserMaxTouchPoints() uint32 { + if x != nil { + return x.BrowserMaxTouchPoints + } + return 0 +} + var File_teleport_devicetrust_v1_device_web_token_proto protoreflect.FileDescriptor const file_teleport_devicetrust_v1_device_web_token_proto_rawDesc = "" + "\n" + - ".teleport/devicetrust/v1/device_web_token.proto\x12\x17teleport.devicetrust.v1\"\xed\x01\n" + + ".teleport/devicetrust/v1/device_web_token.proto\x12\x17teleport.devicetrust.v1\"\xa6\x02\n" + "\x0eDeviceWebToken\x12\x0e\n" + "\x02id\x18\x01 \x01(\tR\x02id\x12\x14\n" + "\x05token\x18\x02 \x01(\tR\x05token\x12$\n" + @@ -168,7 +181,8 @@ const file_teleport_devicetrust_v1_device_web_token_proto_rawDesc = "" + "\n" + "browser_ip\x18\x05 \x01(\tR\tbrowserIp\x12\x12\n" + "\x04user\x18\x06 \x01(\tR\x04user\x12.\n" + - "\x13expected_device_ids\x18\a \x03(\tR\x11expectedDeviceIdsBZZXgithub.com/gravitational/teleport/api/gen/proto/go/teleport/devicetrust/v1;devicetrustv1b\x06proto3" + "\x13expected_device_ids\x18\a \x03(\tR\x11expectedDeviceIds\x127\n" + + "\x18browser_max_touch_points\x18\b \x01(\rR\x15browserMaxTouchPointsBZZXgithub.com/gravitational/teleport/api/gen/proto/go/teleport/devicetrust/v1;devicetrustv1b\x06proto3" var ( file_teleport_devicetrust_v1_device_web_token_proto_rawDescOnce sync.Once diff --git a/api/proto/teleport/devicetrust/v1/device_web_token.proto b/api/proto/teleport/devicetrust/v1/device_web_token.proto index 13d70dc55d8..4663eb83fd1 100644 --- a/api/proto/teleport/devicetrust/v1/device_web_token.proto +++ b/api/proto/teleport/devicetrust/v1/device_web_token.proto @@ -60,4 +60,11 @@ message DeviceWebToken { // Used internally by the Device Trust system. // Transient. repeated string expected_device_ids = 7; + + // BrowserMaxTouchPoints indicates whether the client device supports touch controls. It is sent + // by the frontend app to the proxy service and then forwarded to the auth service. It + // differentiates iPadOS from macOS since they both use the same user agent otherwise. This + // information is needed to decide whether to show the Device Trust prompt in the Web UI after a + // successful login. + uint32 browser_max_touch_points = 8; } diff --git a/gen/proto/ts/teleport/devicetrust/v1/device_web_token_pb.ts b/gen/proto/ts/teleport/devicetrust/v1/device_web_token_pb.ts index 5c49576aae1..3e4a42c8ece 100644 --- a/gen/proto/ts/teleport/devicetrust/v1/device_web_token_pb.ts +++ b/gen/proto/ts/teleport/devicetrust/v1/device_web_token_pb.ts @@ -95,6 +95,16 @@ export interface DeviceWebToken { * @generated from protobuf field: repeated string expected_device_ids = 7; */ expectedDeviceIds: string[]; + /** + * BrowserMaxTouchPoints indicates whether the client device supports touch controls. It is sent + * by the frontend app to the proxy service and then forwarded to the auth service. It + * differentiates iPadOS from macOS since they both use the same user agent otherwise. This + * information is needed to decide whether to show the Device Trust prompt in the Web UI after a + * successful login. + * + * @generated from protobuf field: uint32 browser_max_touch_points = 8; + */ + browserMaxTouchPoints: number; } // @generated message type with reflection information, may provide speed optimized methods class DeviceWebToken$Type extends MessageType { @@ -106,7 +116,8 @@ class DeviceWebToken$Type extends MessageType { { no: 4, name: "browser_user_agent", kind: "scalar", T: 9 /*ScalarType.STRING*/ }, { no: 5, name: "browser_ip", kind: "scalar", T: 9 /*ScalarType.STRING*/ }, { no: 6, name: "user", kind: "scalar", T: 9 /*ScalarType.STRING*/ }, - { no: 7, name: "expected_device_ids", kind: "scalar", repeat: 2 /*RepeatType.UNPACKED*/, T: 9 /*ScalarType.STRING*/ } + { no: 7, name: "expected_device_ids", kind: "scalar", repeat: 2 /*RepeatType.UNPACKED*/, T: 9 /*ScalarType.STRING*/ }, + { no: 8, name: "browser_max_touch_points", kind: "scalar", T: 13 /*ScalarType.UINT32*/ } ]); } create(value?: PartialMessage): DeviceWebToken { @@ -118,6 +129,7 @@ class DeviceWebToken$Type extends MessageType { message.browserIp = ""; message.user = ""; message.expectedDeviceIds = []; + message.browserMaxTouchPoints = 0; if (value !== undefined) reflectionMergePartial(this, message, value); return message; @@ -148,6 +160,9 @@ class DeviceWebToken$Type extends MessageType { case /* repeated string expected_device_ids */ 7: message.expectedDeviceIds.push(reader.string()); break; + case /* uint32 browser_max_touch_points */ 8: + message.browserMaxTouchPoints = reader.uint32(); + break; default: let u = options.readUnknownField; if (u === "throw") @@ -181,6 +196,9 @@ class DeviceWebToken$Type extends MessageType { /* repeated string expected_device_ids = 7; */ for (let i = 0; i < message.expectedDeviceIds.length; i++) writer.tag(7, WireType.LengthDelimited).string(message.expectedDeviceIds[i]); + /* uint32 browser_max_touch_points = 8; */ + if (message.browserMaxTouchPoints !== 0) + writer.tag(8, WireType.Varint).uint32(message.browserMaxTouchPoints); let u = options.writeUnknownFields; if (u !== false) (u == true ? UnknownFieldHandler.onWrite : u)(this.typeName, message, writer); diff --git a/lib/auth/authclient/clt.go b/lib/auth/authclient/clt.go index 665ee7157ab..9c1f8ef6468 100644 --- a/lib/auth/authclient/clt.go +++ b/lib/auth/authclient/clt.go @@ -1429,6 +1429,14 @@ type ForwardedClientMetadata struct { // ProxyGroupID is reverse tunnel group ID, used by reverse tunnel agents // in proxy peering mode. ProxyGroupID string `json:"proxy_group_id,omitempty"` + // MaxTouchPoints indicates whether the client device supports touch controls. It is reported by + // JavaScript in the browser and sent by the frontend app through the Max-Touch-Points header. It + // differentiates iPadOS from macOS since they both use the same user agent otherwise. This + // information is needed to decide whether to show the Device Trust prompt in the Web UI after a + // successful login. + // + // Available only in select endpoints which lead to the Device Trust prompt in the Web UI. + MaxTouchPoints int `json:"max_touch_points,omitempty"` } // CheckAndSetDefaults checks and sets defaults diff --git a/lib/auth/methods.go b/lib/auth/methods.go index d61ad8a773d..afca44f514a 100644 --- a/lib/auth/methods.go +++ b/lib/auth/methods.go @@ -699,6 +699,7 @@ func (a *Server) AuthenticateWebUser(ctx context.Context, req authclient.Authent } var loginIP, userAgent, proxyGroupID string + var maxTouchPoints int if cm := req.ClientMetadata; cm != nil { loginIP, _, err = net.SplitHostPort(cm.RemoteAddr) if err != nil { @@ -706,12 +707,14 @@ func (a *Server) AuthenticateWebUser(ctx context.Context, req authclient.Authent } userAgent = cm.UserAgent proxyGroupID = cm.ProxyGroupID + maxTouchPoints = cm.MaxTouchPoints } sess, err := a.CreateWebSessionFromReq(ctx, NewWebSessionRequest{ User: user.GetName(), LoginIP: loginIP, LoginUserAgent: userAgent, + LoginMaxTouchPoints: maxTouchPoints, ProxyGroupID: proxyGroupID, Roles: user.GetRoles(), Traits: user.GetTraits(), diff --git a/lib/auth/sessions.go b/lib/auth/sessions.go index 5faa170db41..87a945730d5 100644 --- a/lib/auth/sessions.go +++ b/lib/auth/sessions.go @@ -59,6 +59,11 @@ type NewWebSessionRequest struct { // LoginUserAgent is the user agent of the client's browser, as captured by // the Proxy. LoginUserAgent string + // LoginMaxTouchPoints indicates whether the client device supports touch controls. It is sent by + // the frontend app to the proxy service and then forwarded to the auth service. It differentiates + // iPadOS from macOS since they both use the same user agent otherwise. This information is needed + // to decide whether to show the Device Trust prompt in the Web UI after a successful login. + LoginMaxTouchPoints int // ProxyGroupID is the proxy group id where request is generated. ProxyGroupID string // Roles optionally lists additional user roles @@ -128,7 +133,7 @@ func (a *Server) CreateWebSessionFromReq(ctx context.Context, req NewWebSessionR // Issue and assign the DeviceWebToken, but never persist it with the // session. if req.CreateDeviceWebToken { - if err := a.augmentSessionForDeviceTrust(ctx, session, req.LoginIP, req.LoginUserAgent); err != nil { + if err := a.augmentSessionForDeviceTrust(ctx, session, req.LoginIP, req.LoginUserAgent, req.LoginMaxTouchPoints); err != nil { return nil, trace.Wrap(err) } } @@ -140,6 +145,7 @@ func (a *Server) augmentSessionForDeviceTrust( ctx context.Context, session types.WebSession, loginIP, userAgent string, + maxTouchPoints int, ) error { // IP and user agent are mandatory for device web authentication. if loginIP == "" || userAgent == "" { @@ -150,10 +156,11 @@ func (a *Server) augmentSessionForDeviceTrust( // We only get a token if the server is enabled for Device Trust and the user // has a suitable trusted device. webToken, err := a.createDeviceWebToken(ctx, &devicepb.DeviceWebToken{ - WebSessionId: session.GetName(), - BrowserUserAgent: userAgent, - BrowserIp: loginIP, - User: session.GetUser(), + WebSessionId: session.GetName(), + BrowserMaxTouchPoints: uint32(maxTouchPoints), + BrowserUserAgent: userAgent, + BrowserIp: loginIP, + User: session.GetUser(), }) switch { case err != nil: diff --git a/lib/auth/sessions_test.go b/lib/auth/sessions_test.go index 25722760687..437b351e501 100644 --- a/lib/auth/sessions_test.go +++ b/lib/auth/sessions_test.go @@ -21,7 +21,7 @@ import ( "testing" "time" - "github.com/google/go-cmp/cmp" + "github.com/google/uuid" "github.com/jonboulle/clockwork" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" @@ -31,6 +31,7 @@ import ( "github.com/gravitational/teleport/lib/auth" "github.com/gravitational/teleport/lib/auth/authtest" "github.com/gravitational/teleport/lib/defaults" + "github.com/gravitational/teleport/lib/utils" ) func TestCreateWebSession(t *testing.T) { @@ -117,19 +118,23 @@ func TestServer_CreateWebSessionFromReq_deviceWebToken(t *testing.T) { }) authServer := testAuthServer.AuthServer - ctx := context.Background() - // Wire a fake CreateDeviceWebTokenFunc to authServer. - fakeWebToken := &devicepb.DeviceWebToken{ - Id: "423f10ed-c3c1-4de7-99dc-3bc5b9ab7fd5", - Token: "409d21e4-9563-497f-9393-1209f9e4289c", - } - wantToken := &types.DeviceWebToken{ - Id: fakeWebToken.Id, - Token: fakeWebToken.Token, - } + var storedWebTokens utils.SyncMap[string, *devicepb.DeviceWebToken] authServer.SetCreateDeviceWebTokenFunc(func(ctx context.Context, dwt *devicepb.DeviceWebToken) (*devicepb.DeviceWebToken, error) { - return fakeWebToken, nil + if dwt.BrowserMaxTouchPoints > 1 { + // Simulate CreateDeviceWebToken not creating tokens for iPads. + return nil, nil + } + + dwt.Id = uuid.NewString() + dwt.Token = uuid.NewString() + + storedWebTokens.Store(dwt.Id, dwt) + + return &devicepb.DeviceWebToken{ + Id: dwt.Id, + Token: dwt.Token, + }, nil }) const userLlama = "llama" @@ -140,22 +145,54 @@ func TestServer_CreateWebSessionFromReq_deviceWebToken(t *testing.T) { const loginIP = "40.89.244.232" const loginUserAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36" - t.Run("ok", func(t *testing.T) { - session, err := authServer.CreateWebSessionFromReq(ctx, auth.NewWebSessionRequest{ - User: userLlama, - LoginIP: loginIP, - LoginUserAgent: loginUserAgent, - Roles: user.GetRoles(), - Traits: user.GetTraits(), - SessionTTL: 1 * time.Minute, - LoginTime: time.Now(), - CreateDeviceWebToken: true, - }) - require.NoError(t, err, "CreateWebSessionFromReq failed") + tests := []struct { + name string + loginMaxTouchPoints int + wantWebToken bool + }{ + { + name: "macOS", + loginMaxTouchPoints: 0, + wantWebToken: true, + }, + { + name: "iPadOS", + loginMaxTouchPoints: 5, + wantWebToken: false, + }, + } - gotToken := session.GetDeviceWebToken() - if diff := cmp.Diff(wantToken, gotToken); diff != "" { - t.Errorf("CreateWebSessionFromReq DeviceWebToken mismatch (-want +got)\n%s", diff) - } - }) + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + + session, err := authServer.CreateWebSessionFromReq(t.Context(), auth.NewWebSessionRequest{ + User: userLlama, + LoginIP: loginIP, + LoginUserAgent: loginUserAgent, + LoginMaxTouchPoints: test.loginMaxTouchPoints, + Roles: user.GetRoles(), + Traits: user.GetTraits(), + SessionTTL: 1 * time.Minute, + LoginTime: time.Now(), + CreateDeviceWebToken: true, + }) + require.NoError(t, err, "CreateWebSessionFromReq failed") + + gotToken := session.GetDeviceWebToken() + if !test.wantWebToken { + require.Nil(t, gotToken, "device web token was created for this session") + return + } + + require.NotNil(t, gotToken, "device web token was not created for this session") + storedWebToken, ok := storedWebTokens.Load(gotToken.Id) + require.True(t, ok, "created web token was not found") + + require.Equal(t, storedWebToken.Token, gotToken.Token) + require.Equal(t, loginIP, storedWebToken.BrowserIp) + require.Equal(t, loginUserAgent, storedWebToken.BrowserUserAgent) + require.Equal(t, test.loginMaxTouchPoints, int(storedWebToken.BrowserMaxTouchPoints)) + }) + } } diff --git a/lib/web/apiserver.go b/lib/web/apiserver.go index e09d81855b4..3b690524e41 100644 --- a/lib/web/apiserver.go +++ b/lib/web/apiserver.go @@ -2792,9 +2792,20 @@ func (h *Handler) createWebSession(w http.ResponseWriter, r *http.Request, p htt } func clientMetaFromReq(r *http.Request) *authclient.ForwardedClientMetadata { + var maxTouchPoints int + // The frontend client sends Max-Touch-Points only to endpoints that lead to the Device Trust + // prompt in the Web UI. + rawMaxTouchPoints := r.Header.Get("Max-Touch-Points") + if rawMaxTouchPoints != "" { + if value, err := strconv.Atoi(rawMaxTouchPoints); err == nil { + maxTouchPoints = value + } + } + return &authclient.ForwardedClientMetadata{ - UserAgent: r.UserAgent(), - RemoteAddr: r.RemoteAddr, + UserAgent: r.UserAgent(), + RemoteAddr: r.RemoteAddr, + MaxTouchPoints: maxTouchPoints, } } diff --git a/lib/web/apiserver_test.go b/lib/web/apiserver_test.go index 92a0b5d23eb..d5e3dad112f 100644 --- a/lib/web/apiserver_test.go +++ b/lib/web/apiserver_test.go @@ -959,11 +959,13 @@ func Test_clientMetaFromReq(t *testing.T) { http.MethodGet, "https://example.com/webapi/foo", nil, ) r.Header.Set("User-Agent", ua) + r.Header.Set("Max-Touch-Points", "5") got := clientMetaFromReq(r) require.Equal(t, &authclient.ForwardedClientMetadata{ - UserAgent: ua, - RemoteAddr: "192.0.2.1:1234", + UserAgent: ua, + RemoteAddr: "192.0.2.1:1234", + MaxTouchPoints: 5, }, got) } diff --git a/web/packages/teleport/src/services/api/api.test.ts b/web/packages/teleport/src/services/api/api.test.ts index 985a639952d..499adc13fdf 100644 --- a/web/packages/teleport/src/services/api/api.test.ts +++ b/web/packages/teleport/src/services/api/api.test.ts @@ -19,6 +19,7 @@ import { MfaChallengeResponse } from '../mfa'; import websession from '../websession'; import api, { + defaultHeaders, defaultRequestOptions, getAuthHeaders, isUserSessionRoleNotFoundError, @@ -175,6 +176,91 @@ describe('api.fetch', () => { }); }); +describe('api.postWithOptions', () => { + let mockedFetch: jest.SpiedFunction; + beforeEach(() => { + mockedFetch = jest + .spyOn(global, 'fetch') + .mockResolvedValue({ json: async () => ({}), ok: true } as Response); // we don't care about response + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + const authHeaders = { + 'X-CSRF-Token': expect.any(String), + Authorization: expect.stringMatching(/^Bearer .+/), + }; + + // eslint-disable-next-line jest/expect-expect + it('accepts either data or formData', () => { + // @ts-expect-error Either formData or data should be accepted but not both. + api.postWithOptions('/foo', { + formData: new FormData(), + data: { foo: 'bar' }, + }); + }); + + it('stringifies data', () => { + api.postWithOptions('/foo', { data: { foo: 'bar' } }); + expect(mockedFetch).toHaveBeenCalledTimes(1); + expect(mockedFetch).toHaveBeenCalledWith( + 'http://localhost/foo', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ foo: 'bar' }), + }) + ); + }); + + it('keeps default headers when not passing headers through options and not using formData', () => { + api.postWithOptions('/foo'); + expect(mockedFetch).toHaveBeenCalledWith( + 'http://localhost/foo', + expect.objectContaining({ + headers: { ...defaultHeaders, ...authHeaders }, + }) + ); + }); + + it('overrides default headers with headers from options when not using formData', () => { + api.postWithOptions('/foo', { headers: { foo: 'bar' } }); + expect(mockedFetch).toHaveBeenCalledWith( + 'http://localhost/foo', + expect.objectContaining({ + headers: { + foo: 'bar', + ...authHeaders, + }, + }) + ); + }); + + it('overrides default headers when using formData and not passing headers through options', () => { + api.postWithOptions('/foo', { formData: new FormData() }); + expect(mockedFetch).toHaveBeenCalledWith( + 'http://localhost/foo', + expect.objectContaining({ + headers: { Accept: 'application/json', ...authHeaders }, + }) + ); + }); + + it('merges headers from options with overriden headers when using formData', () => { + api.postWithOptions('/foo', { + formData: new FormData(), + headers: { foo: 'bar', Accept: 'foo/bar' }, + }); + expect(mockedFetch).toHaveBeenCalledWith( + 'http://localhost/foo', + expect.objectContaining({ + headers: { foo: 'bar', Accept: 'application/json', ...authHeaders }, + }) + ); + }); +}); + // The code below should guard us from changes to api.fetchJsonWithMfaAuthnRetry which would cause it to lose type // information, for example by returning `any`. diff --git a/web/packages/teleport/src/services/api/api.ts b/web/packages/teleport/src/services/api/api.ts index b683402ca9f..1f8a9abc37f 100644 --- a/web/packages/teleport/src/services/api/api.ts +++ b/web/packages/teleport/src/services/api/api.ts @@ -85,6 +85,62 @@ const api = { throw new Error('data for body is not a type of FormData'); }, + /** + * postWithOptions makes a POST request. Optionally accepts data xor formData. The headers field + * overrides defaultHeaders but not auth headers (see getAuthHeaders). + * + * When passing formData, it always overrides default headers with Accept: 'application/json' to + * avoid setting Content-Type to let the browser infer Content-Type from formData. Always adds + * Accept: 'application/json' to custom headers when formData is used. + */ + postWithOptions( + url: string, + options: Partial<{ + headers: Record; + mfaResponse: MfaChallengeResponse; + signal: AbortSignal; + }> & + // Either data or formData. + (| { data?: unknown; formData?: never } + | { data?: never; formData?: FormData } + ) = {} + ) { + let body: RequestInit['body']; + let headers: RequestInit['headers'] = options.headers; + + if (options.data) { + body = JSON.stringify(options.data); + } else if (options.formData) { + body = options.formData; + // Override headers so that Content-Type is not set to the default one from `defaultRequestOptions`. + // Do not set Content-Type directly to let the browser infer Content-Type for FormData types + // to set the correct boundary: + // 1) https://developer.mozilla.org/en-US/docs/Web/API/FormData/Using_FormData_Objects#sending_files_using_a_formdata_object + // 2) https://stackoverflow.com/a/64653976 + headers = { + ...(options.headers || {}), + Accept: 'application/json', + }; + } + + const customOptions: RequestInit = { + method: 'POST', + body, + signal: options.signal, + }; + // Special handling for header merging logic from api.fetch. + // Passing { headers: undefined } would cause api.fetch to completely ignore default headers. + if (headers) { + customOptions.headers = headers; + } + + return api.fetchJsonWithMfaAuthnRetry( + url, + customOptions, + options.mfaResponse + ); + }, + /** @deprecated Use `deleteWithOptions` instead. */ delete(url: string, data?: unknown, mfaResponse?: MfaChallengeResponse) { return api.deleteWithOptions(url, { @@ -351,12 +407,14 @@ const api = { }, }; +export const defaultHeaders: Readonly> = { + Accept: 'application/json', + 'Content-Type': 'application/json; charset=utf-8', +}; + export const defaultRequestOptions: RequestInit = { credentials: 'same-origin', - headers: { - Accept: 'application/json', - 'Content-Type': 'application/json; charset=utf-8', - }, + headers: defaultHeaders, mode: 'same-origin', cache: 'no-store', }; diff --git a/web/packages/teleport/src/services/auth/auth.test.ts b/web/packages/teleport/src/services/auth/auth.test.ts index 4baa524a435..ae298fa7608 100644 --- a/web/packages/teleport/src/services/auth/auth.test.ts +++ b/web/packages/teleport/src/services/auth/auth.test.ts @@ -30,18 +30,23 @@ describe('services/auth', () => { const email = 'user@example.com'; test('login()', async () => { - jest.spyOn(api, 'post').mockResolvedValue({}); + jest.spyOn(api, 'postWithOptions').mockResolvedValue({}); await auth.login(email, password, ''); - expect(api.post).toHaveBeenCalledWith(cfg.api.webSessionPath, { - user: email, - pass: password, - second_factor_token: '', - }); + expect(api.postWithOptions).toHaveBeenCalledWith( + cfg.api.webSessionPath, + expect.objectContaining({ + data: { + user: email, + pass: password, + second_factor_token: '', + }, + }) + ); }); test('login() OTP', async () => { - jest.spyOn(api, 'post').mockResolvedValue({}); + jest.spyOn(api, 'postWithOptions').mockResolvedValue({}); const data = { user: email, pass: password, @@ -49,7 +54,10 @@ describe('services/auth', () => { }; await auth.login(email, password, 'xxx'); - expect(api.post).toHaveBeenCalledWith(cfg.api.webSessionPath, data); + expect(api.postWithOptions).toHaveBeenCalledWith( + cfg.api.webSessionPath, + expect.objectContaining({ data }) + ); }); test('resetPassword()', async () => { diff --git a/web/packages/teleport/src/services/auth/auth.ts b/web/packages/teleport/src/services/auth/auth.ts index 59bdeeebfd7..a25604637d6 100644 --- a/web/packages/teleport/src/services/auth/auth.ts +++ b/web/packages/teleport/src/services/auth/auth.ts @@ -28,6 +28,7 @@ import { } from 'teleport/services/mfa'; import { CaptureEvent, userEventService } from 'teleport/services/userEvent'; +import { defaultHeaders } from '../api/api'; import { makeWebauthnAssertionResponse, makeWebauthnCreationResponse, @@ -122,7 +123,13 @@ const auth = { second_factor_token: otpCode, }; - return api.post(cfg.api.webSessionPath, data); + return api.postWithOptions(cfg.api.webSessionPath, { + data, + headers: { + ...defaultHeaders, + [HEADER_MAX_TOUCH_POINTS]: navigator.maxTouchPoints?.toString(10), + }, + }); }, loginWithWebauthn(creds?: UserCredentials) { @@ -141,7 +148,13 @@ const auth = { webauthnAssertionResponse: makeWebauthnAssertionResponse(res), }; - return api.post(cfg.api.mfaLoginFinish, request); + return api.postWithOptions(cfg.api.mfaLoginFinish, { + data: request, + headers: { + ...defaultHeaders, + [HEADER_MAX_TOUCH_POINTS]: navigator.maxTouchPoints?.toString(10), + }, + }); }); }, @@ -530,3 +543,9 @@ export enum MfaChallengeScope { ADMIN_ACTION = 7, CHANGE_PASSWORD = 8, } + +/** + * Header which reports navigator.maxTouchPoints to the proxy service. This piece of information is + * later used by the Device Trust service. + */ +const HEADER_MAX_TOUCH_POINTS = 'Max-Touch-Points'; diff --git a/web/packages/teleterm/src/ui/DocumentAuthorizeWebSession/DocumentAuthorizeWebSession.story.tsx b/web/packages/teleterm/src/ui/DocumentAuthorizeWebSession/DocumentAuthorizeWebSession.story.tsx index aea9e16799c..1909ec9d104 100644 --- a/web/packages/teleterm/src/ui/DocumentAuthorizeWebSession/DocumentAuthorizeWebSession.story.tsx +++ b/web/packages/teleterm/src/ui/DocumentAuthorizeWebSession/DocumentAuthorizeWebSession.story.tsx @@ -74,7 +74,7 @@ export function Story(props: StoryProps) { appContext.clustersService.setState(draftState => { draftState.clusters.set(rootCluster.uri, rootCluster); }); - appContext.clustersService.authenticateWebDevice = async () => { + appContext.tshd.authenticateWebDevice = async () => { await wait(2_000); return new MockedUnaryCall({ confirmationToken: { diff --git a/web/packages/teleterm/src/ui/DocumentAuthorizeWebSession/DocumentAuthorizeWebSession.tsx b/web/packages/teleterm/src/ui/DocumentAuthorizeWebSession/DocumentAuthorizeWebSession.tsx index c527af05bd4..932b18a1b19 100644 --- a/web/packages/teleterm/src/ui/DocumentAuthorizeWebSession/DocumentAuthorizeWebSession.tsx +++ b/web/packages/teleterm/src/ui/DocumentAuthorizeWebSession/DocumentAuthorizeWebSession.tsx @@ -19,6 +19,7 @@ import { Alert, ButtonPrimary, ButtonText, H1, Text } from 'design'; import Flex from 'design/Flex'; import { DeviceConfirmationToken } from 'gen-proto-ts/teleport/devicetrust/v1/device_confirmation_token_pb'; +import { DeviceWebToken } from 'gen-proto-ts/teleport/devicetrust/v1/device_web_token_pb'; import { Cluster } from 'gen-proto-ts/teleport/lib/teleterm/v1/cluster_pb'; import { Attempt, useAsync } from 'shared/hooks/useAsync'; import { processRedirectUri } from 'shared/redirects'; @@ -36,19 +37,22 @@ export function DocumentAuthorizeWebSession(props: { visible: boolean; }) { const ctx = useAppContext(); + const { tshd } = ctx; const { documentsService } = useWorkspaceContext(); const rootCluster = ctx.clustersService.findCluster(props.doc.rootClusterUri); - const [authorizeAttempt, authorize] = useAsync(async () => { - const { - response: { confirmationToken }, - } = await retryWithRelogin(ctx, props.doc.rootClusterUri, () => - ctx.clustersService.authenticateWebDevice( - props.doc.rootClusterUri, - props.doc.webSessionRequest - ) - ); - return confirmationToken; - }); + const [authorizeAttempt, authorize] = useAsync(() => + retryWithRelogin(ctx, props.doc.rootClusterUri, () => + tshd + .authenticateWebDevice({ + rootClusterUri: props.doc.rootClusterUri, + deviceWebToken: DeviceWebToken.create({ + id: props.doc.webSessionRequest.id, + token: props.doc.webSessionRequest.token, + }), + }) + .then(({ response: { confirmationToken } }) => confirmationToken) + ) + ); const clusterName = routing.parseClusterName(props.doc.rootClusterUri); const isDeviceTrusted = rootCluster.loggedInUser?.isDeviceTrusted; const isRequestedUserLoggedIn = diff --git a/web/packages/teleterm/src/ui/services/clusters/clustersService.ts b/web/packages/teleterm/src/ui/services/clusters/clustersService.ts index 19806324102..a1b062c5cca 100644 --- a/web/packages/teleterm/src/ui/services/clusters/clustersService.ts +++ b/web/packages/teleterm/src/ui/services/clusters/clustersService.ts @@ -72,31 +72,6 @@ export class ClustersService extends ImmutableStore { this.subscribeToClusterStore(); } - async authenticateWebDevice( - rootClusterUri: uri.RootClusterUri, - { - id, - token, - }: { - id: string; - token: string; - } - ) { - return await this.client.authenticateWebDevice({ - rootClusterUri, - deviceWebToken: { - id, - token, - // empty fields, ignore - webSessionId: '', - browserIp: '', - browserUserAgent: '', - user: '', - expectedDeviceIds: [], - }, - }); - } - /** * Synchronizes the cluster state and starts a headless watcher for it. * It shows errors as notifications.