From d05fb15e8bfe749640645bd3cd381cac6aa1348b Mon Sep 17 00:00:00 2001 From: Neil Trini Lasrado Date: Wed, 16 Sep 2026 23:24:55 +0530 Subject: [PATCH] Add OIDC proxy handlers at /webapi/oidc/* OSS registers proxy routes for GitHub SSO only, so even with an OIDC connector configured and an OIDCService registered there is nothing to serve the login or callback. The web UI already renders a button for OIDC connectors and tsh already posts to these paths, so both were pointing at endpoints that returned 404. Adds oidcLoginWeb, oidcLoginConsole and oidcCallback, mirroring the GitHub handlers including the device-web-token redirect, the console certificate hand-off, and the RedirectURLWithError fallback resolved via GetOIDCAuthRequest. Route registration uses the same middleware wrappers as the GitHub block. The ErrGithubNoTeams branch is not carried across: it is GitHub-team specific and has no OIDC equivalent in the tree. Co-Authored-By: Claude Opus 5 --- lib/web/apiserver.go | 5 + lib/web/oidc.go | 197 +++++++++++++++++++++++++++ lib/web/oidc_test.go | 317 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 519 insertions(+) create mode 100644 lib/web/oidc.go create mode 100644 lib/web/oidc_test.go 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") + }) +}