diff --git a/docs/authentication.md b/docs/authentication.md index 8bb7f50..3a9f6bf 100644 --- a/docs/authentication.md +++ b/docs/authentication.md @@ -36,6 +36,6 @@ Use the Fly dashboard Secrets UI, or `fly secrets import --app context4ai-source ## Sign-in screen -The sign-in screen follows the site language and theme. With no configured provider it explains the missing Google/GitHub configuration and links to this setup guide; configured providers appear as sign-in buttons. A failed provider lookup offers retry without blocking the public browsing link. Login preserves the requested local Source Graph page. +The sign-in screen follows the site language and theme. With no configured provider it explains the missing Google/GitHub configuration and links to this setup guide; configured providers appear as sign-in buttons. A failed provider lookup offers retry without blocking the public browsing link. Login preserves the requested local Source Graph page. Sign-in must start on the configured site address because the provider always returns there and the login cookie belongs to one host. A login started on another address, such as the Fly hostname or an alias reached without the site's proxy, is first sent to the sign-in screen on the site address; the proxy forwards the original host in `X-Forwarded-Host`. Registration denial is a separate, server-rendered HTTP 403 page with the configured bilingual notice, a project link, and a five-second return to the validated previous page. This is distinct from missing OAuth configuration and provider lookup failures. diff --git a/internal/sso/handler.go b/internal/sso/handler.go index 0895d74..dcc5f8e 100644 --- a/internal/sso/handler.go +++ b/internal/sso/handler.go @@ -220,7 +220,36 @@ func (h *Handler) ServeHTTP(w http.ResponseWriter, r *http.Request) { fail(w, 404, "NOT_FOUND") } } + +// siteLogin returns the sign-in page on the configured site address when a login +// starts on another address (the Fly hostname, an alias). The login binding cookie +// is host-only and the provider always returns to the configured address, so a +// login started elsewhere cannot complete. The site's reverse proxy forwards the +// original host; only the configured origin is ever used as a redirect target. +func (h *Handler) siteLogin(r *http.Request) string { + site, err := url.Parse(h.config.Origin) + if err != nil || site.Host == "" { + return "" + } + host := r.Host + if forwarded := strings.TrimSpace(strings.Split(r.Header.Get("X-Forwarded-Host"), ",")[0]); forwarded != "" { + host = forwarded + } + if host == "" || strings.EqualFold(host, site.Host) { + return "" + } + target := safeReturn(r.URL.Query().Get("return_to")) + if target == "" { + target = "/sourcegraph/" + } + return strings.TrimSuffix(h.config.Origin, "/") + "/sourcegraph/login?return_to=" + url.QueryEscape(target) +} + func (h *Handler) login(w http.ResponseWriter, r *http.Request) { + if target := h.siteLogin(r); target != "" { + http.Redirect(w, r, target, http.StatusFound) + return + } if r.Header.Get("Sec-Fetch-Site") == "cross-site" { fail(w, 403, "CROSS_SITE_LOGIN") return diff --git a/internal/sso/site_login_test.go b/internal/sso/site_login_test.go new file mode 100644 index 0000000..3e1e3ae --- /dev/null +++ b/internal/sso/site_login_test.go @@ -0,0 +1,45 @@ +package sso + +import ( + "context" + "net/http/httptest" + "path/filepath" + "strings" + "testing" + + "github.com/context4ai/sourcegraph/internal/sqlstore" + "golang.org/x/oauth2" +) + +func TestLoginStartsOnTheSiteAddress(t *testing.T) { + db, err := sqlstore.Open(context.Background(), "sqlite", filepath.Join(t.TempDir(), "auth.db")) + if err != nil { + t.Fatal(err) + } + h := &Handler{config: Config{Enabled: true, Origin: "https://context4ai.org"}, store: &SQLStore{DB: db}, Provider: "github", + oauth: oauth2.Config{ClientID: "id", RedirectURL: "https://context4ai.org/sourcegraph/auth/github/callback", Endpoint: oauth2.Endpoint{AuthURL: "https://github.com/login/oauth/authorize"}}} + start := func(host, forwarded string) (int, string, string) { + r := httptest.NewRequest("GET", "http://"+host+"/sourcegraph/auth/login?return_to=%2Fsourcegraph%2Frepos", nil) + r.Host = host + if forwarded != "" { + r.Header.Set("X-Forwarded-Host", forwarded) + } + w := httptest.NewRecorder() + h.login(w, r) + return w.Code, w.Header().Get("Location"), w.Header().Get("Set-Cookie") + } + // The Fly hostname and aliases send the browser to the sign-in page on the site. + for _, host := range [][2]string{{"context4ai-sourcegraph.fly.dev", ""}, {"context4ai-sourcegraph.internal:8080", "www.context4ai.org"}} { + code, location, cookie := start(host[0], host[1]) + if code != 302 || location != "https://context4ai.org/sourcegraph/login?return_to=%2Fsourcegraph%2Frepos" || cookie != "" { + t.Fatalf("%v: %d %s %q", host, code, location, cookie) + } + } + // Through the site's proxy (and on the site itself) the login starts at the provider. + for _, host := range [][2]string{{"context4ai-sourcegraph.internal:8080", "context4ai.org"}, {"context4ai.org", ""}} { + code, location, cookie := start(host[0], host[1]) + if code != 302 || !strings.HasPrefix(location, "https://github.com/login/oauth/authorize") || !strings.Contains(cookie, flowCookie+"=") { + t.Fatalf("%v: %d %s %q", host, code, location, cookie) + } + } +}