diff --git a/lib/web/apiserver.go b/lib/web/apiserver.go index 4d8f08ce77eb..a6c73083aa7b 100644 --- a/lib/web/apiserver.go +++ b/lib/web/apiserver.go @@ -981,6 +981,11 @@ func (h *Handler) bindDefaultEndpoints() { h.GET("/webapi/github/callback", h.WithMetaRedirect(h.githubCallback)) h.POST("/webapi/github/login/console", h.WithLimiter(h.githubLoginConsole)) + // OIDC (Google) connector handlers + h.GET("/webapi/oidc/login/web", h.WithRedirect(h.oidcLoginWeb)) + h.GET("/webapi/oidc/callback", h.WithMetaRedirect(h.oidcCallback)) + h.POST("/webapi/oidc/login/console", h.WithLimiter(h.oidcLoginConsole)) + // MFA public endpoints. h.POST("/webapi/sites/:site/mfa/required", h.WithClusterAuth(h.isMFARequired)) h.POST("/webapi/mfa/login/begin", h.WithLimiter(h.mfaLoginBegin)) diff --git a/lib/web/oidc.go b/lib/web/oidc.go new file mode 100644 index 000000000000..f34da2a3eb34 --- /dev/null +++ b/lib/web/oidc.go @@ -0,0 +1,197 @@ +/* + * Teleport + * Copyright (C) 2025 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package web + +import ( + "net" + "net/http" + "strings" + + "github.com/gravitational/trace" + "github.com/julienschmidt/httprouter" + + "github.com/gravitational/teleport/api/types" + "github.com/gravitational/teleport/lib/auth" + "github.com/gravitational/teleport/lib/client" + "github.com/gravitational/teleport/lib/httplib" +) + +// oidcLoginWeb starts the OIDC (Google) login flow initiated from the Web UI. +// It mirrors githubLoginWeb. +func (h *Handler) oidcLoginWeb(w http.ResponseWriter, r *http.Request, p httprouter.Params) string { + logger := h.logger.With("auth", "oidc") + logger.DebugContext(r.Context(), "Web login start") + + req, err := ParseSSORequestParams(r) + if err != nil { + logger.ErrorContext(r.Context(), "Failed to extract SSO parameters from request", "error", err) + return client.LoginFailedRedirectURL + } + + remoteAddr, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + logger.ErrorContext(r.Context(), "Failed to parse request remote address", "error", err) + return client.LoginFailedRedirectURL + } + + response, err := h.cfg.ProxyClient.CreateOIDCAuthRequest(r.Context(), types.OIDCAuthRequest{ + CSRFToken: req.CSRFToken, + ConnectorID: req.ConnectorID, + CreateWebSession: true, + ClientRedirectURL: req.ClientRedirectURL, + ClientLoginIP: remoteAddr, + ClientUserAgent: r.UserAgent(), + }) + if err != nil { + logger.ErrorContext(r.Context(), "Error creating auth request", "error", err) + return client.LoginFailedRedirectURL + } + + return response.RedirectURL +} + +// oidcLoginConsole starts the OIDC (Google) login flow initiated from tsh +// (console). It mirrors githubLoginConsole. +func (h *Handler) oidcLoginConsole(w http.ResponseWriter, r *http.Request, p httprouter.Params) (interface{}, error) { + logger := h.logger.With("auth", "oidc") + logger.DebugContext(r.Context(), "Console login start") + + req := new(client.SSOLoginConsoleReq) + if err := httplib.ReadResourceJSON(r, req); err != nil { + logger.ErrorContext(r.Context(), "Error reading json", "error", err) + return nil, trace.AccessDenied("%s", SSOLoginFailureMessage) + } + + if err := req.CheckAndSetDefaults(); err != nil { + logger.ErrorContext(r.Context(), "Missing request parameters", "error", err) + return nil, trace.AccessDenied("%s", SSOLoginFailureMessage) + } + + remoteAddr, _, err := net.SplitHostPort(r.RemoteAddr) + if err != nil { + logger.ErrorContext(r.Context(), "Failed to parse request remote address", "error", err) + return nil, trace.AccessDenied("%s", SSOLoginFailureMessage) + } + + response, err := h.cfg.ProxyClient.CreateOIDCAuthRequest(r.Context(), types.OIDCAuthRequest{ + ConnectorID: req.ConnectorID, + SshPublicKey: req.SSHPubKey, + TlsPublicKey: req.TLSPubKey, + SshAttestationStatement: req.SSHAttestationStatement.ToProto(), + TlsAttestationStatement: req.TLSAttestationStatement.ToProto(), + CertTTL: req.CertTTL, + ClientRedirectURL: req.RedirectURL, + Compatibility: req.Compatibility, + RouteToCluster: req.RouteToCluster, + KubernetesCluster: req.KubernetesCluster, + ClientLoginIP: remoteAddr, + Scope: req.Scope, + }) + if err != nil { + logger.ErrorContext(r.Context(), "Failed to create OIDC auth request", "error", err) + if strings.Contains(err.Error(), auth.InvalidClientRedirectErrorMessage) { + return nil, trace.AccessDenied("%s", SSOLoginFailureInvalidRedirect) + } + return nil, trace.AccessDenied("%s", SSOLoginFailureMessage) + } + + return &client.SSOLoginConsoleResponse{ + RedirectURL: response.RedirectURL, + }, nil +} + +// oidcCallback handles the callback from Google (via the OIDC provider) once +// the user has completed authentication. It mirrors githubCallback. +func (h *Handler) oidcCallback(w http.ResponseWriter, r *http.Request, p httprouter.Params) string { + logger := h.logger.With("auth", "oidc") + logger.DebugContext(r.Context(), "Callback start", "query", r.URL.Query()) + + response, err := h.cfg.ProxyClient.ValidateOIDCAuthCallback(r.Context(), r.URL.Query()) + if err != nil { + logger.ErrorContext(r.Context(), "Error while processing callback", "error", err) + + // try to find the auth request, which bears the original client redirect URL. + // if found, use it to terminate the flow. + // + // this improves the UX by terminating the failed SSO flow immediately, rather than hoping for a timeout. + if requestID := r.URL.Query().Get("state"); requestID != "" { + if request, errGet := h.cfg.ProxyClient.GetOIDCAuthRequest(r.Context(), requestID); errGet == nil && !request.CreateWebSession { + if redURL, errEnc := RedirectURLWithError(request.ClientRedirectURL, err); errEnc == nil { + return redURL.String() + } + } + } + + return client.LoginFailedBadCallbackRedirectURL + } + + // if we created web session, set session cookie and redirect to original url + if response.Req.CreateWebSession { + logger.InfoContext(r.Context(), "Redirecting to web browser") + + res := &SSOCallbackResponse{ + CSRFToken: response.Req.CSRFToken, + Username: response.Username, + SessionName: response.Session.GetName(), + SessionExpiry: response.Session.Expiry(), + ClientRedirectURL: response.Req.ClientRedirectURL, + } + + if err := SSOSetWebSessionAndRedirectURL(w, r, res, true); err != nil { + logger.ErrorContext(r.Context(), "Error setting web session.", "error", err) + return client.LoginFailedRedirectURL + } + + if dwt := response.Session.GetDeviceWebToken(); dwt != nil { + logger.DebugContext(r.Context(), "OIDC WebSession created with device web token") + // if a device web token is present, we must send the user to the device authorize page + // to upgrade the session. + redirectPath, err := BuildDeviceWebRedirectPath(dwt, res.ClientRedirectURL) + if err != nil { + logger.DebugContext(r.Context(), "Invalid device web token", "error", err) + } + return redirectPath + } + return res.ClientRedirectURL + } + + logger.InfoContext(r.Context(), "Callback is redirecting to console login") + if len(response.Req.SSHPubKey)+len(response.Req.TLSPubKey) == 0 { + logger.ErrorContext(r.Context(), "Not a web or console login request") + return client.LoginFailedRedirectURL + } + + redirectURL, err := ConstructSSHResponse(AuthParams{ + ClientRedirectURL: response.Req.ClientRedirectURL, + Username: response.Username, + Identity: response.Identity, + Session: response.Session, + Cert: response.Cert, + TLSCert: response.TLSCert, + HostSigners: response.HostSigners, + FIPS: h.cfg.FIPS, + ClientOptions: response.ClientOptions, + }) + if err != nil { + logger.ErrorContext(r.Context(), "Error constructing ssh response", "error", err) + return client.LoginFailedRedirectURL + } + + return redirectURL.String() +} diff --git a/lib/web/oidc_test.go b/lib/web/oidc_test.go new file mode 100644 index 000000000000..367102953d05 --- /dev/null +++ b/lib/web/oidc_test.go @@ -0,0 +1,317 @@ +/* + * Teleport + * Copyright (C) 2025 Gravitational, Inc. + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU Affero General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU Affero General Public License for more details. + * + * You should have received a copy of the GNU Affero General Public License + * along with this program. If not, see . + */ + +package web + +import ( + "bytes" + "context" + "encoding/hex" + "encoding/json" + "io" + "log/slog" + "net/http" + "net/http/httptest" + "net/url" + "testing" + "time" + + "github.com/gravitational/trace" + "github.com/julienschmidt/httprouter" + "github.com/stretchr/testify/require" + + "github.com/gravitational/teleport/api/types" + "github.com/gravitational/teleport/lib/auth" + "github.com/gravitational/teleport/lib/auth/authclient" + "github.com/gravitational/teleport/lib/client" + "github.com/gravitational/teleport/lib/httplib/csrf" +) + +// fakeOIDCAuthClient is a minimal authclient.ClientI stand-in that only +// implements the three methods the OIDC proxy handlers depend on +// (CreateOIDCAuthRequest, ValidateOIDCAuthCallback, GetOIDCAuthRequest). +// This lets the handlers be tested without any real (or Enterprise-gated) +// OIDCService implementation. +type fakeOIDCAuthClient struct { + authclient.ClientI + + createReq *types.OIDCAuthRequest + createErr error + gotCreated types.OIDCAuthRequest + + validateResp *authclient.OIDCAuthResponse + validateErr error + + getReq *types.OIDCAuthRequest + getErr error +} + +func (f *fakeOIDCAuthClient) CreateOIDCAuthRequest(ctx context.Context, req types.OIDCAuthRequest) (*types.OIDCAuthRequest, error) { + f.gotCreated = req + if f.createErr != nil { + return nil, f.createErr + } + return f.createReq, nil +} + +func (f *fakeOIDCAuthClient) ValidateOIDCAuthCallback(ctx context.Context, q url.Values) (*authclient.OIDCAuthResponse, error) { + if f.validateErr != nil { + return nil, f.validateErr + } + return f.validateResp, nil +} + +func (f *fakeOIDCAuthClient) GetOIDCAuthRequest(ctx context.Context, id string) (*types.OIDCAuthRequest, error) { + if f.getErr != nil { + return nil, f.getErr + } + return f.getReq, nil +} + +func newOIDCTestHandler(clt authclient.ClientI) *Handler { + return &Handler{ + cfg: Config{ProxyClient: clt}, + logger: slog.New(slog.NewTextHandler(io.Discard, nil)), + } +} + +// newCSRFCookieToken returns a valid-format CSRF token and a matching cookie +// to attach to a request, so that csrf.ExtractTokenFromCookie/VerifyToken +// succeed in tests. +func newCSRFCookieToken(t *testing.T) (token string, cookie *http.Cookie) { + t.Helper() + raw := bytes.Repeat([]byte{0x42}, 32) + token = hex.EncodeToString(raw) + return token, &http.Cookie{Name: csrf.CookieName, Value: token} +} + +func TestOIDCLoginWeb(t *testing.T) { + t.Run("success redirects to provider URL", func(t *testing.T) { + token, cookie := newCSRFCookieToken(t) + fake := &fakeOIDCAuthClient{ + createReq: &types.OIDCAuthRequest{RedirectURL: "https://accounts.google.com/o/oauth2/auth?foo=bar"}, + } + h := newOIDCTestHandler(fake) + + u := &url.URL{Path: "/webapi/oidc/login/web", RawQuery: url.Values{ + "connector_id": {"google"}, + "redirect_url": {"/web/cluster/foo/nodes"}, + }.Encode()} + r := httptest.NewRequest(http.MethodGet, u.String(), nil) + r.AddCookie(cookie) + r.RemoteAddr = "1.2.3.4:5678" + w := httptest.NewRecorder() + + got := h.oidcLoginWeb(w, r, httprouter.Params{}) + require.Equal(t, "https://accounts.google.com/o/oauth2/auth?foo=bar", got) + require.Equal(t, "google", fake.gotCreated.ConnectorID) + require.True(t, fake.gotCreated.CreateWebSession) + require.Equal(t, token, fake.gotCreated.CSRFToken) + }) + + t.Run("missing redirect_url fails closed", func(t *testing.T) { + fake := &fakeOIDCAuthClient{} + h := newOIDCTestHandler(fake) + + u := &url.URL{Path: "/webapi/oidc/login/web", RawQuery: url.Values{ + "connector_id": {"google"}, + }.Encode()} + r := httptest.NewRequest(http.MethodGet, u.String(), nil) + r.RemoteAddr = "1.2.3.4:5678" + w := httptest.NewRecorder() + + got := h.oidcLoginWeb(w, r, httprouter.Params{}) + require.Equal(t, client.LoginFailedRedirectURL, got) + }) + + t.Run("auth request creation error fails closed", func(t *testing.T) { + _, cookie := newCSRFCookieToken(t) + fake := &fakeOIDCAuthClient{createErr: trace.AccessDenied("OIDC is not available")} + h := newOIDCTestHandler(fake) + + u := &url.URL{Path: "/webapi/oidc/login/web", RawQuery: url.Values{ + "connector_id": {"google"}, + "redirect_url": {"/web"}, + }.Encode()} + r := httptest.NewRequest(http.MethodGet, u.String(), nil) + r.AddCookie(cookie) + r.RemoteAddr = "1.2.3.4:5678" + w := httptest.NewRecorder() + + got := h.oidcLoginWeb(w, r, httprouter.Params{}) + require.Equal(t, client.LoginFailedRedirectURL, got) + }) +} + +func TestOIDCLoginConsole(t *testing.T) { + t.Run("success returns redirect URL", func(t *testing.T) { + fake := &fakeOIDCAuthClient{ + createReq: &types.OIDCAuthRequest{RedirectURL: "https://accounts.google.com/o/oauth2/auth?foo=bar"}, + } + h := newOIDCTestHandler(fake) + + body, err := json.Marshal(client.SSOLoginConsoleReq{ + RedirectURL: "http://127.0.0.1:0/callback?secret_key=abc", + ConnectorID: "google", + CertTTL: time.Hour, + UserPublicKeys: client.UserPublicKeys{ + SSHPubKey: []byte("ssh-ed25519 AAAA fake"), + }, + }) + require.NoError(t, err) + + r := httptest.NewRequest(http.MethodPost, "/webapi/oidc/login/console", bytes.NewReader(body)) + r.Header.Set("Content-Type", "application/json") + r.RemoteAddr = "1.2.3.4:5678" + w := httptest.NewRecorder() + + resp, err := h.oidcLoginConsole(w, r, httprouter.Params{}) + require.NoError(t, err) + consoleResp, ok := resp.(*client.SSOLoginConsoleResponse) + require.True(t, ok) + require.Equal(t, "https://accounts.google.com/o/oauth2/auth?foo=bar", consoleResp.RedirectURL) + require.Equal(t, "google", fake.gotCreated.ConnectorID) + }) + + t.Run("CheckAndSetDefaults failure yields generic error", func(t *testing.T) { + fake := &fakeOIDCAuthClient{} + h := newOIDCTestHandler(fake) + + // Missing ConnectorID. + body, err := json.Marshal(client.SSOLoginConsoleReq{ + RedirectURL: "http://127.0.0.1:0/callback?secret_key=abc", + CertTTL: time.Hour, + }) + require.NoError(t, err) + + r := httptest.NewRequest(http.MethodPost, "/webapi/oidc/login/console", bytes.NewReader(body)) + r.Header.Set("Content-Type", "application/json") + r.RemoteAddr = "1.2.3.4:5678" + w := httptest.NewRecorder() + + _, err = h.oidcLoginConsole(w, r, httprouter.Params{}) + require.True(t, trace.IsAccessDenied(err)) + require.Contains(t, err.Error(), SSOLoginFailureMessage) + }) + + t.Run("invalid client redirect maps to specific error", func(t *testing.T) { + fake := &fakeOIDCAuthClient{ + createErr: trace.Wrap(trace.BadParameter("bad redirect"), auth.InvalidClientRedirectErrorMessage), + } + h := newOIDCTestHandler(fake) + + body, err := json.Marshal(client.SSOLoginConsoleReq{ + RedirectURL: "https://evil.example.com/callback?secret_key=abc", + ConnectorID: "google", + CertTTL: time.Hour, + UserPublicKeys: client.UserPublicKeys{ + SSHPubKey: []byte("ssh-ed25519 AAAA fake"), + }, + }) + require.NoError(t, err) + + r := httptest.NewRequest(http.MethodPost, "/webapi/oidc/login/console", bytes.NewReader(body)) + r.Header.Set("Content-Type", "application/json") + r.RemoteAddr = "1.2.3.4:5678" + w := httptest.NewRecorder() + + _, err = h.oidcLoginConsole(w, r, httprouter.Params{}) + require.True(t, trace.IsAccessDenied(err)) + require.Contains(t, err.Error(), SSOLoginFailureInvalidRedirect) + }) +} + +func TestOIDCCallback(t *testing.T) { + t.Run("failure with known non-web state redirects to client redirect URL with error", func(t *testing.T) { + wantErr := trace.AccessDenied("hosted domain mismatch") + fake := &fakeOIDCAuthClient{ + validateErr: wantErr, + getReq: &types.OIDCAuthRequest{ + CreateWebSession: false, + ClientRedirectURL: "http://127.0.0.1:0/callback?secret_key=abc", + }, + } + h := newOIDCTestHandler(fake) + + r := httptest.NewRequest(http.MethodGet, "/webapi/oidc/callback?state=abc123&code=xyz", nil) + w := httptest.NewRecorder() + + got := h.oidcCallback(w, r, httprouter.Params{}) + + u, err := url.Parse(got) + require.NoError(t, err) + require.Equal(t, wantErr.Error(), u.Query().Get("err")) + }) + + t.Run("failure with unknown state falls back to bad callback URL", func(t *testing.T) { + fake := &fakeOIDCAuthClient{ + validateErr: trace.AccessDenied("boom"), + getErr: trace.NotFound("request not found"), + } + h := newOIDCTestHandler(fake) + + r := httptest.NewRequest(http.MethodGet, "/webapi/oidc/callback?state=missing&code=xyz", nil) + w := httptest.NewRecorder() + + got := h.oidcCallback(w, r, httprouter.Params{}) + require.Equal(t, client.LoginFailedBadCallbackRedirectURL, got) + }) + + t.Run("success sets web session and redirects to client URL", func(t *testing.T) { + token, cookie := newCSRFCookieToken(t) + + session, err := types.NewWebSession("session-id", types.KindWebSession, types.WebSessionSpecV2{ + User: "alice@example.com", + Pub: []byte("not-a-real-cert"), + BearerToken: "bearer-token", + BearerTokenExpires: time.Now().Add(time.Hour), + Expires: time.Now().Add(time.Hour), + }) + require.NoError(t, err) + + fake := &fakeOIDCAuthClient{ + validateResp: &authclient.OIDCAuthResponse{ + Username: "alice@example.com", + Session: session, + Req: authclient.OIDCAuthRequest{ + CSRFToken: token, + CreateWebSession: true, + ClientRedirectURL: "/web/cluster/foo/nodes", + }, + }, + } + h := newOIDCTestHandler(fake) + + r := httptest.NewRequest(http.MethodGet, "/webapi/oidc/callback?state=abc123&code=xyz", nil) + r.AddCookie(cookie) + w := httptest.NewRecorder() + + got := h.oidcCallback(w, r, httprouter.Params{}) + require.Equal(t, "/web/cluster/foo/nodes", got) + + // A session cookie should have been set. + resp := w.Result() + var found bool + for _, c := range resp.Cookies() { + if c.Name != "" { + found = true + } + } + require.True(t, found, "expected a session cookie to be set") + }) +}