diff --git a/lib/client/api.go b/lib/client/api.go
index c3b456af2f7e5..b45dd46e19811 100644
--- a/lib/client/api.go
+++ b/lib/client/api.go
@@ -25,6 +25,7 @@ import (
"fmt"
"io"
"net"
+ "net/url"
"os"
"os/exec"
"os/user"
@@ -3450,7 +3451,16 @@ func (tc *TeleportClient) mfaLocalLogin(ctx context.Context, priv *keys.PrivateK
func (tc *TeleportClient) headlessLogin(ctx context.Context, priv *keys.PrivateKey) (*auth.SSHLoginResponse, error) {
headlessAuthenticationID := services.NewHeadlessAuthenticationID(priv.MarshalSSHPublicKey())
- fmt.Fprintf(tc.Stdout, "Complete headless authentication in your local web browser:\ntsh headless approve --user=%v --proxy=%v %v\n", tc.Username, tc.WebProxyAddr, headlessAuthenticationID)
+
+ webUILink, err := url.JoinPath("https://"+tc.WebProxyAddr, "web", "headless", headlessAuthenticationID)
+ if err != nil {
+ return nil, trace.Wrap(err)
+ }
+
+ tshApprove := fmt.Sprintf("tsh headless approve --user=%v --proxy=%v %v", tc.Username, tc.WebProxyAddr, headlessAuthenticationID)
+
+ fmt.Fprintf(tc.Stdout, "Complete headless authentication in your local web browser:\n\n%s\n"+
+ "\nor execute this command in your local terminal:\n\n%s\n", webUILink, tshApprove)
response, err := SSHAgentHeadlessLogin(ctx, SSHLoginHeadless{
SSHLogin: SSHLogin{
diff --git a/lib/client/weblogin.go b/lib/client/weblogin.go
index 6916e42a22ba3..6ca06870ef304 100644
--- a/lib/client/weblogin.go
+++ b/lib/client/weblogin.go
@@ -194,6 +194,13 @@ type AuthenticateWebUserRequest struct {
WebauthnAssertionResponse *wanlib.CredentialAssertionResponse `json:"webauthnAssertionResponse,omitempty"`
}
+type HeadlessRequest struct {
+ // Actions can be either accept or deny.
+ Action string `json:"action"`
+ // WebauthnAssertionResponse is a signed WebAuthn credential assertion.
+ WebauthnAssertionResponse *wanlib.CredentialAssertionResponse `json:"webauthnAssertionResponse,omitempty"`
+}
+
// SSHLogin contains common SSH login parameters.
type SSHLogin struct {
// ProxyAddr is the target proxy address
diff --git a/lib/web/apiserver.go b/lib/web/apiserver.go
index b4227adf1b0a8..93a5dc1728324 100644
--- a/lib/web/apiserver.go
+++ b/lib/web/apiserver.go
@@ -717,6 +717,9 @@ func (h *Handler) bindDefaultEndpoints() {
h.POST("/webapi/precapture", h.WithLimiter(h.createPreUserEventHandle))
// create authenticated user events.
h.POST("/webapi/capture", h.WithAuth(h.createUserEventHandle))
+
+ h.GET("/webapi/headless/:headless_authentication_id", h.WithAuth(h.getHeadless))
+ h.PUT("/webapi/headless/:headless_authentication_id", h.WithAuth(h.putHeadlessState))
}
// GetProxyClient returns authenticated auth server client
diff --git a/lib/web/headless.go b/lib/web/headless.go
new file mode 100644
index 0000000000000..527bec83b33fd
--- /dev/null
+++ b/lib/web/headless.go
@@ -0,0 +1,109 @@
+/*
+
+ Copyright 2023 Gravitational, Inc.
+
+ Licensed under the Apache License, Version 2.0 (the "License");
+ you may not use this file except in compliance with the License.
+ You may obtain a copy of the License at
+
+ http://www.apache.org/licenses/LICENSE-2.0
+
+ Unless required by applicable law or agreed to in writing, software
+ distributed under the License is distributed on an "AS IS" BASIS,
+ WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ See the License for the specific language governing permissions and
+ limitations under the License.
+
+
+*/
+
+package web
+
+import (
+ "net/http"
+
+ "github.com/gravitational/trace"
+ "github.com/julienschmidt/httprouter"
+
+ "github.com/gravitational/teleport/api/client/proto"
+ "github.com/gravitational/teleport/api/types"
+ wanlib "github.com/gravitational/teleport/lib/auth/webauthn"
+ "github.com/gravitational/teleport/lib/client"
+ "github.com/gravitational/teleport/lib/httplib"
+)
+
+const headlessAuthID = "headless_authentication_id"
+
+func (h *Handler) getHeadless(_ http.ResponseWriter, r *http.Request, params httprouter.Params, sctx *SessionContext) (any, error) {
+ headlessAuthenticationID, err := getHeadlessAuthID(params)
+ if err != nil {
+ return nil, trace.Wrap(err)
+ }
+
+ authClient, err := sctx.GetClient()
+ if err != nil {
+ return nil, trace.Wrap(err)
+ }
+
+ headlessAuthn, err := authClient.GetHeadlessAuthentication(r.Context(), headlessAuthenticationID)
+ if err != nil {
+ // Log the error, but return something more user-friendly.
+ // Context exceeded or invalid request states are more confusing than helpful.
+ h.log.Debug("failed to get headless session: %v", err)
+
+ return nil, trace.BadParameter("requested invalid headless session")
+ }
+
+ return headlessAuthn, nil
+}
+
+func (h *Handler) putHeadlessState(_ http.ResponseWriter, r *http.Request, params httprouter.Params, sctx *SessionContext) (any, error) {
+ headlessAuthenticationID, err := getHeadlessAuthID(params)
+ if err != nil {
+ return nil, trace.Wrap(err)
+ }
+
+ var req client.HeadlessRequest
+ if err := httplib.ReadJSON(r, &req); err != nil {
+ return nil, trace.Wrap(err)
+ }
+
+ var action types.HeadlessAuthenticationState
+ var resp = &proto.MFAAuthenticateResponse{}
+
+ switch req.Action {
+ case "accept":
+ action = types.HeadlessAuthenticationState_HEADLESS_AUTHENTICATION_STATE_APPROVED
+ resp = &proto.MFAAuthenticateResponse{
+ Response: &proto.MFAAuthenticateResponse_Webauthn{
+ Webauthn: wanlib.CredentialAssertionResponseToProto(req.WebauthnAssertionResponse),
+ },
+ }
+ case "denied":
+ action = types.HeadlessAuthenticationState_HEADLESS_AUTHENTICATION_STATE_DENIED
+ default:
+ return nil, trace.BadParameter("unknown action %s", req.Action)
+ }
+
+ authClient, err := sctx.GetClient()
+ if err != nil {
+ return nil, trace.Wrap(err)
+ }
+
+ err = authClient.UpdateHeadlessAuthenticationState(r.Context(), headlessAuthenticationID,
+ action, resp)
+ if err != nil {
+ return nil, trace.Wrap(err)
+ }
+
+ // WebUI expects a JSON response.
+ return OK(), nil
+}
+
+func getHeadlessAuthID(params httprouter.Params) (string, error) {
+ headlessAuthenticationID := params.ByName(headlessAuthID)
+ if headlessAuthenticationID == "" {
+ return "", trace.BadParameter("request is missing headless authentication ID")
+ }
+ return headlessAuthenticationID, nil
+}
diff --git a/web/packages/teleport/src/HeadlessRequest/Cards.tsx b/web/packages/teleport/src/HeadlessRequest/Cards.tsx
new file mode 100644
index 0000000000000..8394a02b46513
--- /dev/null
+++ b/web/packages/teleport/src/HeadlessRequest/Cards.tsx
@@ -0,0 +1,39 @@
+/*
+ *
+ * Copyright 2023 Gravitational, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+import { Card, CardSuccess, Text } from 'design';
+import { CircleStop } from 'design/Icon';
+import React from 'react';
+
+export function CardDenied({ title, children }) {
+ return (
+
+
+ {title && (
+
+ {title}
+
+ )}
+ {children}
+
+ );
+}
+
+export function CardAccept({ title, children }) {
+ return {children};
+}
diff --git a/web/packages/teleport/src/HeadlessRequest/HeadlessRequest.test.tsx b/web/packages/teleport/src/HeadlessRequest/HeadlessRequest.test.tsx
new file mode 100644
index 0000000000000..d9f5c82458660
--- /dev/null
+++ b/web/packages/teleport/src/HeadlessRequest/HeadlessRequest.test.tsx
@@ -0,0 +1,51 @@
+/**
+ * Copyright 2023 Gravitational, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+import { render, screen } from 'design/utils/testing';
+import React from 'react';
+import { Route, Router } from 'react-router';
+import { createMemoryHistory } from 'history';
+
+import { HeadlessRequest } from 'teleport/HeadlessRequest/HeadlessRequest';
+import cfg from 'teleport/config';
+import auth from 'teleport/services/auth';
+
+test('ip address should be visible', async () => {
+ jest.spyOn(auth, 'headlessSSOGet').mockImplementation(
+ () =>
+ new Promise(resolve => {
+ resolve({ clientIpAddress: '1.2.3.4' });
+ })
+ );
+
+ const headlessSSOPath = '/web/headless/2a8dcaae-1fa5-533b-aad8-f97420df44de';
+ const mockHistory = createMemoryHistory({
+ initialEntries: [headlessSSOPath],
+ });
+
+ render(
+
+
+
+
+
+ );
+
+ await expect(
+ screen.findByText(/Someone has initiated a command from 1.2.3.4/i)
+ ).resolves.toBeInTheDocument();
+});
diff --git a/web/packages/teleport/src/HeadlessRequest/HeadlessRequest.tsx b/web/packages/teleport/src/HeadlessRequest/HeadlessRequest.tsx
new file mode 100644
index 0000000000000..a99c524e6e084
--- /dev/null
+++ b/web/packages/teleport/src/HeadlessRequest/HeadlessRequest.tsx
@@ -0,0 +1,149 @@
+/**
+ * Copyright 2023 Gravitational, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+import React, { useEffect, useState } from 'react';
+import styled from 'styled-components';
+
+import { Spinner } from 'design/Icon';
+import { Box, Flex } from 'design';
+
+import auth from 'teleport/services/auth';
+import { useParams } from 'teleport/components/Router';
+import HeadlessRequestDialog from 'teleport/components/HeadlessRequestDialog/HeadlessRequestDialog';
+import { CardAccept, CardDenied } from 'teleport/HeadlessRequest/Cards';
+
+export function HeadlessRequest() {
+ const { requestId } = useParams<{ requestId: string }>();
+
+ const [state, setState] = useState({
+ ipAddress: '',
+ status: 'pending',
+ errorText: '',
+ publicKey: null as PublicKeyCredentialRequestOptions,
+ });
+
+ useEffect(() => {
+ const setIpAddress = (response: { clientIpAddress: string }) => {
+ setState({
+ ...state,
+ status: 'loaded',
+ ipAddress: response.clientIpAddress,
+ });
+ };
+
+ auth
+ .headlessSSOGet(requestId)
+ .then(setIpAddress)
+ .catch(e => {
+ setState({
+ ...state,
+ status: 'error',
+ errorText: e.toString(),
+ });
+ });
+ }, [requestId]);
+
+ const setSuccess = () => {
+ setState({ ...state, status: 'success' });
+ };
+
+ const setRejected = () => {
+ setState({ ...state, status: 'rejected' });
+ };
+
+ if (state.status == 'pending') {
+ return (
+
+
+
+
+
+ );
+ }
+
+ if (state.status == 'success') {
+ return (
+
+ You can now return to your terminal.
+
+ );
+ }
+
+ if (state.status == 'rejected') {
+ return (
+
+ The request has been rejected.
+
+ );
+ }
+
+ return (
+ {
+ setState({ ...state, status: 'in-progress' });
+
+ auth
+ .headlessSSOAccept(requestId)
+ .then(setSuccess)
+ .catch(e => {
+ setState({
+ ...state,
+ status: 'error',
+ errorText: e.toString(),
+ });
+ });
+ }}
+ onReject={() => {
+ setState({ ...state, status: 'in-progress' });
+
+ auth
+ .headlessSSOReject(requestId)
+ .then(setRejected)
+ .catch(e => {
+ setState({
+ ...state,
+ status: 'error',
+ errorText: e.toString(),
+ });
+ });
+ }}
+ errorText={state.errorText}
+ />
+ );
+}
+
+const Spin = styled(Box)`
+ line-height: 12px;
+ font-size: 24px;
+ animation: spin 1s linear infinite;
+ @keyframes spin {
+ from {
+ transform: rotate(0deg);
+ }
+ to {
+ transform: rotate(360deg);
+ }
+ }
+`;
diff --git a/web/packages/teleport/src/HeadlessRequest/index.ts b/web/packages/teleport/src/HeadlessRequest/index.ts
new file mode 100644
index 0000000000000..702099fd9d384
--- /dev/null
+++ b/web/packages/teleport/src/HeadlessRequest/index.ts
@@ -0,0 +1,18 @@
+/**
+ * Copyright 2023 Gravitational, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+// export as default for use with React.lazy
+export { HeadlessRequest as default } from './HeadlessRequest';
diff --git a/web/packages/teleport/src/Teleport.tsx b/web/packages/teleport/src/Teleport.tsx
index 9185846c8a27c..f91f44f05af8a 100644
--- a/web/packages/teleport/src/Teleport.tsx
+++ b/web/packages/teleport/src/Teleport.tsx
@@ -89,6 +89,10 @@ const DesktopSession = React.lazy(
() => import(/* webpackChunkName: "desktop-session" */ './DesktopSession')
);
+const HeadlessRequest = React.lazy(
+ () => import(/* webpackChunkName: "headless-request" */ './HeadlessRequest')
+);
+
const Main = React.lazy(() => import(/* webpackChunkName: "main" */ './Main'));
function publicOSSRoutes() {
@@ -159,6 +163,11 @@ export function getSharedPrivateRoutes() {
/>,
,
,
+ ,
];
}
diff --git a/web/packages/teleport/src/components/HeadlessRequestDialog/HeadlessRequestDialog.tsx b/web/packages/teleport/src/components/HeadlessRequestDialog/HeadlessRequestDialog.tsx
new file mode 100644
index 0000000000000..21a07120bbed1
--- /dev/null
+++ b/web/packages/teleport/src/components/HeadlessRequestDialog/HeadlessRequestDialog.tsx
@@ -0,0 +1,86 @@
+/**
+ * Copyright 2023 Gravitational, Inc.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ */
+
+import React from 'react';
+import Dialog, {
+ DialogContent,
+ DialogFooter,
+ DialogHeader,
+ DialogTitle,
+} from 'design/Dialog';
+import { Danger } from 'design/Alert';
+import { ButtonPrimary, ButtonSecondary, Text } from 'design';
+
+export default function HeadlessRequestDialog({
+ ipAddress,
+ onAccept,
+ onReject,
+ errorText,
+}: Props) {
+ return (
+
+ );
+}
+
+export type Props = {
+ ipAddress: string;
+ onAccept: () => void;
+ onReject: () => void;
+ errorText: string;
+};
diff --git a/web/packages/teleport/src/config.ts b/web/packages/teleport/src/config.ts
index 67400560bdbde..115674c6dac58 100644
--- a/web/packages/teleport/src/config.ts
+++ b/web/packages/teleport/src/config.ts
@@ -110,6 +110,7 @@ const cfg = {
userReset: '/web/reset/:tokenId',
userResetContinue: '/web/reset/:tokenId/continue',
kubernetes: '/web/cluster/:clusterId/kubernetes',
+ headlessSso: `/web/headless/:requestId`,
// whitelist sso handlers
oidcHandler: '/v1/webapi/oidc/*',
samlHandler: '/v1/webapi/saml/*',
@@ -178,6 +179,8 @@ const cfg = {
mfaLoginFinish: '/v1/webapi/mfa/login/finishsession', // creates a web session
mfaChangePasswordBegin: '/v1/webapi/mfa/authenticatechallenge/password',
+ headlessSsoPath: `/v1/webapi/headless/:requestId`,
+
mfaCreateRegistrationChallengePath:
'/v1/webapi/mfa/token/:tokenId/registerchallenge',
@@ -199,6 +202,8 @@ const cfg = {
captureUserEventPath: '/v1/webapi/capture',
capturePreUserEventPath: '/v1/webapi/precapture',
+
+ headlessLogin: '/v1/webapi/headless/:headless_authentication_id',
},
getAppFqdnUrl(params: UrlAppParams) {
@@ -425,6 +430,10 @@ const cfg = {
return generatePath(cfg.routes.userResetContinue, { tokenId });
},
+ getHeadlessSsoPath(requestId: string) {
+ return generatePath(cfg.api.headlessSsoPath, { requestId });
+ },
+
getUserInviteTokenRoute(tokenId = '') {
return generatePath(cfg.routes.userInvite, { tokenId });
},
diff --git a/web/packages/teleport/src/services/auth/auth.ts b/web/packages/teleport/src/services/auth/auth.ts
index a662a2c79e6e9..d33e193de27c6 100644
--- a/web/packages/teleport/src/services/auth/auth.ts
+++ b/web/packages/teleport/src/services/auth/auth.ts
@@ -26,7 +26,7 @@ import {
makeWebauthnAssertionResponse,
makeWebauthnCreationResponse,
} from './makeMfa';
-import { UserCredentials, NewCredentialRequest } from './types';
+import { NewCredentialRequest, UserCredentials } from './types';
const auth = {
checkWebauthnSupport() {
@@ -66,7 +66,7 @@ const auth = {
// mfaLoginBegin retrieves users mfa challenges for their
// registered devices. Empty creds indicates request for passwordless challenges.
- // Otherwise non-passwordless challenges requires creds to be verified.
+ // Otherwise, non-passwordless challenges requires creds to be verified.
mfaLoginBegin(creds?: UserCredentials) {
return api
.post(cfg.api.mfaLoginBegin, {
@@ -195,6 +195,46 @@ const auth = {
});
},
+ headlessSSOGet(transactionId: string) {
+ return auth
+ .checkWebauthnSupport()
+ .then(() => api.get(cfg.getHeadlessSsoPath(transactionId)))
+ .then((json: any) => {
+ json = json || {};
+
+ return {
+ clientIpAddress: json.client_ip_address,
+ };
+ });
+ },
+
+ headlessSSOAccept(transactionId: string) {
+ return auth
+ .checkWebauthnSupport()
+ .then(() => api.post(cfg.api.mfaAuthnChallengePath))
+ .then(res =>
+ navigator.credentials.get({
+ publicKey: makeMfaAuthenticateChallenge(res).webauthnPublicKey,
+ })
+ )
+ .then(res => {
+ const request = {
+ action: 'accept',
+ webauthnAssertionResponse: makeWebauthnAssertionResponse(res),
+ };
+
+ return api.put(cfg.getHeadlessSsoPath(transactionId), request);
+ });
+ },
+
+ headlessSSOReject(transactionId: string) {
+ const request = {
+ action: 'denied',
+ };
+
+ return api.put(cfg.getHeadlessSsoPath(transactionId), request);
+ },
+
createPrivilegeTokenWithTotp(secondFactorToken: string) {
return api.post(cfg.api.createPrivilegeTokenPath, { secondFactorToken });
},