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.