diff --git a/docs/github-app.md b/docs/github-app.md index d9c029b..f03cfa1 100644 --- a/docs/github-app.md +++ b/docs/github-app.md @@ -146,8 +146,8 @@ If GitHub sent them back after **Install & Authorize**, Syncidian records the `i | Symptom | Likely cause | | --- | --- | | “This instance has no GitHub App yet” | Finish Option A or B. Confirm `/admin` shows **Registered**, or the `SYNCIDIAN_GITHUB_APP_*` variables are set. | -| OAuth error / redirect mismatch | Callback URL on the GitHub App must be exactly `{base}/api/v1/auth/github/callback`. `{base}` must be the origin in the address bar (scheme + host + port). | -| Redirect URI error after changing the public domain | App ID, Client ID, client secret, and private key stay the same. Edit the **existing** GitHub App: Homepage, Callback, Setup, and Webhook must all use the new `{base}` (for hosted: `https://syncidian.com`). Do not create a new app. | +| OAuth error / redirect mismatch / GitHub **Be careful!** *The `redirect_uri` is not associated with this application* | The GitHub App’s **Callback URL** (Identifying and authorizing users) must be exactly `{base}/api/v1/auth/github/callback`. After moving off a Railway hostname, add `https://syncidian.com/api/v1/auth/github/callback` on the **existing** app and save. Do not create a new app. | +| Redirect URI error after changing the public domain | App ID, Client ID, client secret, and private key stay the same. Edit the **existing** GitHub App: Homepage, Callback, Setup, and Webhook must all use the new `{base}` (for hosted: `https://syncidian.com`). | | Install succeeds but Syncidian says credentials are missing | Setup URL must be `{base}/api/v1/github/app/setup`. Sign in as a **non-admin** vault user; admins do not connect a repo. | | Install & Authorize succeeds but dashboard still says **Not configured** | With OAuth during installation, GitHub redirects to the **Callback URL** with `installation_id`. Syncidian must receive that on `/api/v1/auth/github/callback` while you are signed in. Click **Connect with GitHub** again from the dashboard (do not open the install URL in a private window). Confirm Callback URL is exactly `{base}/api/v1/auth/github/callback`. | | Cannot install on someone else’s GitHub account | Make the app installable on **Any account** (public to GitHub users, not the Marketplace). | diff --git a/internal/server/auth_github.go b/internal/server/auth_github.go index 047043b..768af3d 100644 --- a/internal/server/auth_github.go +++ b/internal/server/auth_github.go @@ -66,7 +66,7 @@ func (s *Server) handleGitHubAuthStart(w http.ResponseWriter, r *http.Request) { Secure: r.TLS != nil || strings.EqualFold(r.Header.Get("X-Forwarded-Proto"), "https"), MaxAge: 600, }) - urls := githubapp.AppURLs(s.requestBase(r)) + urls := githubapp.AppURLs(s.githubBase(r)) q := url.Values{ "client_id": {app.ClientID}, "redirect_uri": {urls.Callback}, @@ -115,7 +115,7 @@ func (s *Server) handleGitHubAuthCallback(w http.ResponseWriter, r *http.Request s.dashboardRedirect(w, r, url.Values{"github": {"error"}, "message": {"GitHub App is not registered."}}) return } - urls := githubapp.AppURLs(s.requestBase(r)) + urls := githubapp.AppURLs(s.githubBase(r)) token, err := githubapp.ExchangeOAuth(app.ClientID, app.ClientSecret, r.URL.Query().Get("code"), urls.Callback) if err != nil { s.dashboardRedirect(w, r, url.Values{"github": {"error"}, "message": {err.Error()}}) diff --git a/internal/server/auth_github_allowlist_test.go b/internal/server/auth_github_allowlist_test.go index 9a2365f..672ae70 100644 --- a/internal/server/auth_github_allowlist_test.go +++ b/internal/server/auth_github_allowlist_test.go @@ -4,6 +4,8 @@ import ( "encoding/json" "net/http" "net/http/httptest" + "net/url" + "strings" "testing" "github.com/shangeethsivan/Syncidian/internal/config" @@ -78,6 +80,52 @@ func TestSetupHidesGitHubSignInOnHostedDomain(t *testing.T) { } } +func TestGitHubOAuthRedirectUsesPublicURL(t *testing.T) { + dir := t.TempDir() + st, err := store.Open(dir) + if err != nil { + t.Fatal(err) + } + defer st.Close() + srv := New(config.Config{ + Addr: ":0", DataDir: dir, PublicURL: "https://syncidian.com", + }, st, nil) + hs := httptest.NewServer(srv.Handler()) + defer hs.Close() + + adminCookies := setupAdmin(t, hs) + res, m := doJSON(t, http.MethodPost, hs.URL+"/api/v1/github/app/register", map[string]any{ + "app_id": 42, "slug": "syncidian", "pem": "-----BEGIN PRIVATE KEY-----\nMIIB\n-----END PRIVATE KEY-----", + "client_id": "Iv1.cb", "client_secret": "secret", + }, adminCookies, "") + if res.StatusCode != 200 { + t.Fatalf("register: %d %v", res.StatusCode, m) + } + + req, err := http.NewRequest(http.MethodGet, hs.URL+"/api/v1/auth/github/start", nil) + if err != nil { + t.Fatal(err) + } + req.Host = "something.up.railway.app" + req.Header.Set("X-Forwarded-Proto", "https") + req.Header.Set("X-Forwarded-Host", "something.up.railway.app") + redir, err := noFollowClient().Do(req) + if err != nil { + t.Fatal(err) + } + redir.Body.Close() + if redir.StatusCode != http.StatusFound { + t.Fatalf("start: %d", redir.StatusCode) + } + loc := redir.Header.Get("Location") + if !strings.Contains(loc, "redirect_uri="+url.QueryEscape("https://syncidian.com/api/v1/auth/github/callback")) { + t.Fatalf("OAuth must use PublicURL callback, not the Railway host: %q", loc) + } + if strings.Contains(loc, "railway.app") { + t.Fatalf("OAuth must not send a Railway redirect_uri: %q", loc) + } +} + func TestSetupShowsGitHubSignInOnLocalhost(t *testing.T) { hs, done := newTestServer(t) defer done() diff --git a/internal/server/github_app.go b/internal/server/github_app.go index ebb248c..f39df2f 100644 --- a/internal/server/github_app.go +++ b/internal/server/github_app.go @@ -291,13 +291,12 @@ func (s *Server) requestBase(r *http.Request) string { return proto + "://" + host } -// githubBase is the origin GitHub can reach (callback, setup, webhook). -// Operator UI on a Tailscale-only hostname must still advertise the public URL. +// githubBase is the origin GitHub can reach (callback, setup, webhook, OAuth). +// Prefer SYNCIDIAN_PUBLIC_URL so sign-in always uses the URL registered on the +// GitHub App (custom domain), not a Tailscale or Railway hostname. func (s *Server) githubBase(r *http.Request) string { - if s.isAdminHost(r) { - if u := strings.TrimRight(s.Cfg.PublicURL, "/"); u != "" { - return u - } + if u := strings.TrimRight(s.Cfg.PublicURL, "/"); u != "" { + return u } return s.requestBase(r) } diff --git a/internal/server/server_test.go b/internal/server/server_test.go index e25bc57..8e04f0f 100644 --- a/internal/server/server_test.go +++ b/internal/server/server_test.go @@ -487,6 +487,8 @@ func TestDashboardServed(t *testing.T) { for _, needle := range []string{ `id="landing"`, `id="landing-brand"`, + `id="adm-callback-public"`, + `redirect_uri is not associated`, `github-signin`, `syncidian-github-signin`, `Continue with GitHub`, diff --git a/internal/web/static/index.html b/internal/web/static/index.html index 7d64b7c..1d578ce 100644 --- a/internal/web/static/index.html +++ b/internal/web/static/index.html @@ -831,6 +831,7 @@

Setup walkthrough

if (!urls) return; const set = (id, v) => { const el = $(id); if (el) el.textContent = v; }; set("adm-callback", urls.callback); + set("adm-callback-public", urls.callback); set("adm-setup", urls.setup); set("adm-webhook", urls.webhook); } @@ -1385,7 +1386,11 @@

GitHub App

: `Not registered ${waitlistOn() ? "People cannot sign in with GitHub until an operator registers the app." : "Email login already works. Register the app only if you want GitHub sign-in or per-user backup."}`}

- ${nerdsOn() ? "" : `

Enable Stats for Nerds for callback URLs, Create GitHub App, and credential paste.

`} + ${publicInfo && publicInfo.github_app && publicInfo.github_app.configured && publicInfo.github_app.urls && publicInfo.github_app.urls.callback ? ` +

GitHub sign-in sends people to this Callback URL. It must be listed on the GitHub App (Identifying and authorizing users). If GitHub shows The redirect_uri is not associated with this application, add this exact URL and save — do not leave a Railway hostname as the only callback after you attach a custom domain.

+

${esc(publicInfo.github_app.urls.callback)}

+

Open GitHub App settings

` : ""} + ${nerdsOn() ? "" : `

Enable Stats for Nerds for setup/webhook URLs, Create GitHub App, and credential paste.

`} ${nerdsOn() ? `