mirror of
https://github.com/gravitational/teleport.git
synced 2026-09-21 14:35:22 +08:00
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
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
+19
-1
@@ -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<DeviceWebToken> {
|
||||
@@ -106,7 +116,8 @@ class DeviceWebToken$Type extends MessageType<DeviceWebToken> {
|
||||
{ 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>): DeviceWebToken {
|
||||
@@ -118,6 +129,7 @@ class DeviceWebToken$Type extends MessageType<DeviceWebToken> {
|
||||
message.browserIp = "";
|
||||
message.user = "";
|
||||
message.expectedDeviceIds = [];
|
||||
message.browserMaxTouchPoints = 0;
|
||||
if (value !== undefined)
|
||||
reflectionMergePartial<DeviceWebToken>(this, message, value);
|
||||
return message;
|
||||
@@ -148,6 +160,9 @@ class DeviceWebToken$Type extends MessageType<DeviceWebToken> {
|
||||
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<DeviceWebToken> {
|
||||
/* 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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(),
|
||||
|
||||
+12
-5
@@ -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:
|
||||
|
||||
+66
-29
@@ -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))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
+13
-2
@@ -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,
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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<typeof fetch>;
|
||||
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`.
|
||||
|
||||
|
||||
@@ -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<string, string>;
|
||||
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<Record<string, string>> = {
|
||||
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',
|
||||
};
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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';
|
||||
|
||||
+1
-1
@@ -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: {
|
||||
|
||||
+15
-11
@@ -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 =
|
||||
|
||||
@@ -72,31 +72,6 @@ export class ClustersService extends ImmutableStore<ClustersServiceState> {
|
||||
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.
|
||||
|
||||
Reference in New Issue
Block a user