From 3f2a63e4c35c6a3bdadf44e6d6e2ab2bdca56c25 Mon Sep 17 00:00:00 2001
From: Cursor Agent
Date: Thu, 3 Sep 2026 04:15:20 +0000
Subject: [PATCH] Send GitHub OAuth to the public callback URL, not the request
host.
Sign-in was using the incoming hostname as redirect_uri. After the custom
domain, that no longer matches the GitHub App. Always use SYNCIDIAN_PUBLIC_URL
and show the exact Callback URL on the operator page.
Co-authored-by: shangeeth95
---
docs/github-app.md | 4 +-
internal/server/auth_github.go | 4 +-
internal/server/auth_github_allowlist_test.go | 48 +++++++++++++++++++
internal/server/github_app.go | 11 ++---
internal/server/server_test.go | 2 +
internal/web/static/index.html | 7 ++-
6 files changed, 65 insertions(+), 11 deletions(-)
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() ? `