fix: fix origin and oauth callback redirectsUnverified
eaf506a parent: 0df9e53 modified
.env.example +15 -17 | @@ -2,15 +2,15 @@ | ||
| 2 | 2 | # |
| 3 | 3 | # ─────────────────────────────── BACKEND (Go API) ────────────────────────────── |
| 4 | 4 | |
| 5 | -# Listen address for the Go HTTP server. | |
| 6 | -GLEAN_ADDR=:8080 | |
| 7 | -# SQLite database path. | |
| 8 | -GLEAN_DB=glean.db | |
| 5 | +# Listen address for the Go HTTP server. In the Docker setup this is loopback | |
| 6 | +# only: the SvelteKit server proxies to it. | |
| 7 | +GLEAN_ADDR=127.0.0.1:8080 | |
| 8 | +# SQLite database base path (_users, _articles, _recs suffixes are appended). | |
| 9 | +GLEAN_DB=/data/glean.db | |
| 9 | 10 | # HMAC key for signing session cookies. Generate with: openssl rand -hex 32 |
| 10 | 11 | GLEAN_SESSION_KEY=change-me-to-a-random-string |
| 11 | -# Public browser origin of the frontend. The backend uses this to validate the | |
| 12 | -# CSRF Origin header and to build the OAuth callback URL. MUST be the origin | |
| 13 | -# users see in their browser (e.g. https://glean.at), NOT the backend's own URL. | |
| 12 | +# Public browser origin of the frontend. MUST be the origin users see in their | |
| 13 | +# browser (e.g. https://glean.at). The OAuth callback URL is derived from this. | |
| 14 | 14 | GLEAN_FRONTEND_URL=https://glean.at |
| 15 | 15 | |
| 16 | 16 | # ATProto / Jetstream. |
| @@ -22,11 +22,11 @@ GLEAN_FETCH_INTERVAL=15m | ||
| 22 | 22 | GLEAN_COLLECTION_DIR_URL=https://relay1.us-west.bsky.network/xrpc/com.atproto.sync.listReposByCollection?collection=at.glean.subscription |
| 23 | 23 | GLEAN_BACKFILL_CONCURRENCY=5 |
| 24 | 24 | |
| 25 | -# OAuth. Leave GLEAN_OAUTH_CLIENT_ID empty for localhost dev (uses OAuth localhost flow). | |
| 26 | -# In production, set both. The callback is served by the FRONTEND at /api/auth/callback | |
| 27 | -# (the frontend proxies it to this backend), so it points at the frontend origin. | |
| 28 | -GLEAN_OAUTH_CLIENT_ID=https://glean.at/oauth/client-metadata | |
| 29 | -GLEAN_OAUTH_REDIRECT_URL=https://glean.at/api/auth/callback | |
| 25 | +# OAuth. Leave GLEAN_OAUTH_CLIENT_ID empty for localhost dev (uses the OAuth | |
| 26 | +# localhost flow). In production set it to this server's client-metadata URL; | |
| 27 | +# it must resolve to /api/oauth/client-metadata on the public origin. | |
| 28 | +# The OAuth callback is always $GLEAN_FRONTEND_URL/api/auth/callback. | |
| 29 | +GLEAN_OAUTH_CLIENT_ID=https://glean.at/api/oauth/client-metadata | |
| 30 | 30 | |
| 31 | 31 | # Embeddings (recommended as it powers content-based feed/article recommendations). |
| 32 | 32 | # Point to any OpenAI-compatible /v1/embeddings endpoint (OpenAI, Ollama, etc.). |
| @@ -50,10 +50,8 @@ GLEAN_LLM_MODEL=qwen3.5-35b-a3b | ||
| 50 | 50 | |
| 51 | 51 | # ─────────────────────────────── FRONTEND (SvelteKit) ─────────────────────────── |
| 52 | 52 | |
| 53 | -# Base URL of the Go API. The SvelteKit server proxies /api/* here server-to-server. | |
| 54 | -GLEAN_API_URL=https://api.glean.at | |
| 55 | -# The public origin users see in their browser. Used by SvelteKit's adapter-node | |
| 56 | -# to set the canonical origin and must match GLEAN_FRONTEND_URL on the backend. | |
| 57 | -ORIGIN=https://glean.at | |
| 53 | +# Where the SvelteKit server finds the Go API. In the Docker setup they share a | |
| 54 | +# network namespace, so this is loopback. | |
| 55 | +GLEAN_API_URL=http://127.0.0.1:8080 | |
| 58 | 56 | # Port the SvelteKit Node server listens on. |
| 59 | 57 | PORT=3000 |
| @@ -2,15 +2,15 @@ | |||
| 2 | # | 2 | # |
| 3 | # ─────────────────────────────── BACKEND (Go API) ────────────────────────────── | 3 | # ─────────────────────────────── BACKEND (Go API) ────────────────────────────── |
| 4 | 4 | ||
| 5 | -# Listen address for the Go HTTP server. | 5 | +# Listen address for the Go HTTP server. In the Docker setup this is loopback |
| 6 | -GLEAN_ADDR=:8080 | 6 | +# only: the SvelteKit server proxies to it. |
| 7 | -# SQLite database path. | 7 | +GLEAN_ADDR=127.0.0.1:8080 |
| 8 | -GLEAN_DB=glean.db | 8 | +# SQLite database base path (_users, _articles, _recs suffixes are appended). |
| 9 | +GLEAN_DB=/data/glean.db | ||
| 9 | # HMAC key for signing session cookies. Generate with: openssl rand -hex 32 | 10 | # HMAC key for signing session cookies. Generate with: openssl rand -hex 32 |
| 10 | GLEAN_SESSION_KEY=change-me-to-a-random-string | 11 | GLEAN_SESSION_KEY=change-me-to-a-random-string |
| 11 | -# Public browser origin of the frontend. The backend uses this to validate the | 12 | +# Public browser origin of the frontend. MUST be the origin users see in their |
| 12 | -# CSRF Origin header and to build the OAuth callback URL. MUST be the origin | 13 | +# browser (e.g. https://glean.at). The OAuth callback URL is derived from this. |
| 13 | -# users see in their browser (e.g. https://glean.at), NOT the backend's own URL. | ||
| 14 | GLEAN_FRONTEND_URL=https://glean.at | 14 | GLEAN_FRONTEND_URL=https://glean.at |
| 15 | 15 | ||
| 16 | # ATProto / Jetstream. | 16 | # ATProto / Jetstream. |
| @@ -22,11 +22,11 @@ GLEAN_FETCH_INTERVAL=15m | |||
| 22 | GLEAN_COLLECTION_DIR_URL=https://relay1.us-west.bsky.network/xrpc/com.atproto.sync.listReposByCollection?collection=at.glean.subscription | 22 | GLEAN_COLLECTION_DIR_URL=https://relay1.us-west.bsky.network/xrpc/com.atproto.sync.listReposByCollection?collection=at.glean.subscription |
| 23 | GLEAN_BACKFILL_CONCURRENCY=5 | 23 | GLEAN_BACKFILL_CONCURRENCY=5 |
| 24 | 24 | ||
| 25 | -# OAuth. Leave GLEAN_OAUTH_CLIENT_ID empty for localhost dev (uses OAuth localhost flow). | 25 | +# OAuth. Leave GLEAN_OAUTH_CLIENT_ID empty for localhost dev (uses the OAuth |
| 26 | -# In production, set both. The callback is served by the FRONTEND at /api/auth/callback | 26 | +# localhost flow). In production set it to this server's client-metadata URL; |
| 27 | -# (the frontend proxies it to this backend), so it points at the frontend origin. | 27 | +# it must resolve to /api/oauth/client-metadata on the public origin. |
| 28 | -GLEAN_OAUTH_CLIENT_ID=https://glean.at/oauth/client-metadata | 28 | +# The OAuth callback is always $GLEAN_FRONTEND_URL/api/auth/callback. |
| 29 | -GLEAN_OAUTH_REDIRECT_URL=https://glean.at/api/auth/callback | 29 | +GLEAN_OAUTH_CLIENT_ID=https://glean.at/api/oauth/client-metadata |
| 30 | 30 | ||
| 31 | # Embeddings (recommended as it powers content-based feed/article recommendations). | 31 | # Embeddings (recommended as it powers content-based feed/article recommendations). |
| 32 | # Point to any OpenAI-compatible /v1/embeddings endpoint (OpenAI, Ollama, etc.). | 32 | # Point to any OpenAI-compatible /v1/embeddings endpoint (OpenAI, Ollama, etc.). |
| @@ -50,10 +50,8 @@ GLEAN_LLM_MODEL=qwen3.5-35b-a3b | |||
| 50 | 50 | ||
| 51 | # ─────────────────────────────── FRONTEND (SvelteKit) ─────────────────────────── | 51 | # ─────────────────────────────── FRONTEND (SvelteKit) ─────────────────────────── |
| 52 | 52 | ||
| 53 | -# Base URL of the Go API. The SvelteKit server proxies /api/* here server-to-server. | 53 | +# Where the SvelteKit server finds the Go API. In the Docker setup they share a |
| 54 | -GLEAN_API_URL=https://api.glean.at | 54 | +# network namespace, so this is loopback. |
| 55 | -# The public origin users see in their browser. Used by SvelteKit's adapter-node | 55 | +GLEAN_API_URL=http://127.0.0.1:8080 |
| 56 | -# to set the canonical origin and must match GLEAN_FRONTEND_URL on the backend. | ||
| 57 | -ORIGIN=https://glean.at | ||
| 58 | # Port the SvelteKit Node server listens on. | 56 | # Port the SvelteKit Node server listens on. |
| 59 | PORT=3000 | 57 | PORT=3000 |
modified
Dockerfile +0 -1 | @@ -36,7 +36,6 @@ WORKDIR /app/web | ||
| 36 | 36 | # SvelteKit proxies /api to the Go API on localhost:8080. |
| 37 | 37 | ENV GLEAN_API_URL=http://127.0.0.1:8080 |
| 38 | 38 | ENV PORT=3000 |
| 39 | -ENV ORIGIN=http://localhost:3000 | |
| 40 | 39 | |
| 41 | 40 | EXPOSE 3000 |
| 42 | 41 | |
| @@ -36,7 +36,6 @@ WORKDIR /app/web | |||
| 36 | # SvelteKit proxies /api to the Go API on localhost:8080. | 36 | # SvelteKit proxies /api to the Go API on localhost:8080. |
| 37 | ENV GLEAN_API_URL=http://127.0.0.1:8080 | 37 | ENV GLEAN_API_URL=http://127.0.0.1:8080 |
| 38 | ENV PORT=3000 | 38 | ENV PORT=3000 |
| 39 | -ENV ORIGIN=http://localhost:3000 | ||
| 40 | 39 | ||
| 41 | EXPOSE 3000 | 40 | EXPOSE 3000 |
| 42 | 41 | ||
modified
internal/server/auth_handler.go +3 -44 | @@ -66,7 +66,7 @@ func (s *Server) handleAuthStart(w http.ResponseWriter, r *http.Request) { | ||
| 66 | 66 | writeAPIError(w, http.StatusInternalServerError, "Could not create your account. Please try again.") |
| 67 | 67 | return |
| 68 | 68 | } |
| 69 | - s.setUserSession(w, user) | |
| 69 | + s.setUserSession(w, r, user) | |
| 70 | 70 | writeJSON(w, http.StatusOK, redirectResponse{Redirect: "/dashboard"}) |
| 71 | 71 | return |
| 72 | 72 | } |
| @@ -77,38 +77,6 @@ func (s *Server) handleAuthStart(w http.ResponseWriter, r *http.Request) { | ||
| 77 | 77 | // handleAuthCallback is hit by the OAuth provider after authorization. It is a |
| 78 | 78 | // browser navigation (not an XHR), so it issues HTTP redirects rather than JSON. |
| 79 | 79 | func (s *Server) handleAuthCallback(w http.ResponseWriter, r *http.Request) { |
| 80 | - params := r.URL.Query() | |
| 81 | - | |
| 82 | - if params.Get("code") != "" && params.Get("state") != "" { | |
| 83 | - s.handleOAuthCallback(w, r) | |
| 84 | - return | |
| 85 | - } | |
| 86 | - | |
| 87 | - handle := params.Get("handle") | |
| 88 | - if handle == "" { | |
| 89 | - http.Redirect(w, r, "/auth/login?error=missing_handle", http.StatusSeeOther) | |
| 90 | - return | |
| 91 | - } | |
| 92 | - | |
| 93 | - did, err := atproto.ResolveHandle(r.Context(), handle) | |
| 94 | - if err != nil { | |
| 95 | - s.logger.Error("failed to resolve handle", "error", err) | |
| 96 | - http.Redirect(w, r, "/auth/login?error=handle_not_found", http.StatusSeeOther) | |
| 97 | - return | |
| 98 | - } | |
| 99 | - | |
| 100 | - user, err := s.dbs.Users.CreateUser(r.Context(), did) | |
| 101 | - if err != nil { | |
| 102 | - s.logger.Error("failed to create user", "error", err) | |
| 103 | - http.Redirect(w, r, "/auth/login?error=create_failed", http.StatusSeeOther) | |
| 104 | - return | |
| 105 | - } | |
| 106 | - | |
| 107 | - s.setUserSession(w, user) | |
| 108 | - http.Redirect(w, r, "/dashboard", http.StatusSeeOther) | |
| 109 | -} | |
| 110 | - | |
| 111 | -func (s *Server) handleOAuthCallback(w http.ResponseWriter, r *http.Request) { | |
| 112 | 80 | sessData, err := s.oauth.ProcessCallback(r.Context(), r.URL.Query()) |
| 113 | 81 | if err != nil { |
| 114 | 82 | s.logger.Error("OAuth callback failed", "error", err) |
| @@ -117,7 +85,6 @@ func (s *Server) handleOAuthCallback(w http.ResponseWriter, r *http.Request) { | ||
| 117 | 85 | } |
| 118 | 86 | |
| 119 | 87 | did := sessData.AccountDID.String() |
| 120 | - | |
| 121 | 88 | client := s.pdsClientFromSession(sessData) |
| 122 | 89 | |
| 123 | 90 | user, err := s.dbs.Users.CreateUser(r.Context(), did) |
| @@ -139,15 +106,7 @@ func (s *Server) handleOAuthCallback(w http.ResponseWriter, r *http.Request) { | ||
| 139 | 106 | return |
| 140 | 107 | } |
| 141 | 108 | |
| 142 | - http.SetCookie(w, &http.Cookie{ | |
| 143 | - Name: "glean_session", | |
| 144 | - Value: encoded, | |
| 145 | - Path: "/", | |
| 146 | - MaxAge: 86400 * 30, | |
| 147 | - HttpOnly: true, | |
| 148 | - Secure: s.secureCookies, | |
| 149 | - SameSite: http.SameSiteLaxMode, | |
| 150 | - }) | |
| 109 | + http.SetCookie(w, sessionCookie("glean_session", encoded, 86400*30, r)) | |
| 151 | 110 | |
| 152 | 111 | s.syncUserInBackground(user.DID, client) |
| 153 | 112 | |
| @@ -187,6 +146,6 @@ func (s *Server) handleAuthLogout(w http.ResponseWriter, r *http.Request) { | ||
| 187 | 146 | _ = s.oauth.Logout(r.Context(), did, session.SessionID) |
| 188 | 147 | } |
| 189 | 148 | } |
| 190 | - s.clearUserSession(w) | |
| 149 | + s.clearUserSession(w, r) | |
| 191 | 150 | writeJSON(w, http.StatusOK, redirectResponse{Redirect: "/"}) |
| 192 | 151 | } |
| @@ -66,7 +66,7 @@ func (s *Server) handleAuthStart(w http.ResponseWriter, r *http.Request) { | |||
| 66 | writeAPIError(w, http.StatusInternalServerError, "Could not create your account. Please try again.") | 66 | writeAPIError(w, http.StatusInternalServerError, "Could not create your account. Please try again.") |
| 67 | return | 67 | return |
| 68 | } | 68 | } |
| 69 | - s.setUserSession(w, user) | 69 | + s.setUserSession(w, r, user) |
| 70 | writeJSON(w, http.StatusOK, redirectResponse{Redirect: "/dashboard"}) | 70 | writeJSON(w, http.StatusOK, redirectResponse{Redirect: "/dashboard"}) |
| 71 | return | 71 | return |
| 72 | } | 72 | } |
| @@ -77,38 +77,6 @@ func (s *Server) handleAuthStart(w http.ResponseWriter, r *http.Request) { | |||
| 77 | // handleAuthCallback is hit by the OAuth provider after authorization. It is a | 77 | // handleAuthCallback is hit by the OAuth provider after authorization. It is a |
| 78 | // browser navigation (not an XHR), so it issues HTTP redirects rather than JSON. | 78 | // browser navigation (not an XHR), so it issues HTTP redirects rather than JSON. |
| 79 | func (s *Server) handleAuthCallback(w http.ResponseWriter, r *http.Request) { | 79 | func (s *Server) handleAuthCallback(w http.ResponseWriter, r *http.Request) { |
| 80 | - params := r.URL.Query() | ||
| 81 | - | ||
| 82 | - if params.Get("code") != "" && params.Get("state") != "" { | ||
| 83 | - s.handleOAuthCallback(w, r) | ||
| 84 | - return | ||
| 85 | - } | ||
| 86 | - | ||
| 87 | - handle := params.Get("handle") | ||
| 88 | - if handle == "" { | ||
| 89 | - http.Redirect(w, r, "/auth/login?error=missing_handle", http.StatusSeeOther) | ||
| 90 | - return | ||
| 91 | - } | ||
| 92 | - | ||
| 93 | - did, err := atproto.ResolveHandle(r.Context(), handle) | ||
| 94 | - if err != nil { | ||
| 95 | - s.logger.Error("failed to resolve handle", "error", err) | ||
| 96 | - http.Redirect(w, r, "/auth/login?error=handle_not_found", http.StatusSeeOther) | ||
| 97 | - return | ||
| 98 | - } | ||
| 99 | - | ||
| 100 | - user, err := s.dbs.Users.CreateUser(r.Context(), did) | ||
| 101 | - if err != nil { | ||
| 102 | - s.logger.Error("failed to create user", "error", err) | ||
| 103 | - http.Redirect(w, r, "/auth/login?error=create_failed", http.StatusSeeOther) | ||
| 104 | - return | ||
| 105 | - } | ||
| 106 | - | ||
| 107 | - s.setUserSession(w, user) | ||
| 108 | - http.Redirect(w, r, "/dashboard", http.StatusSeeOther) | ||
| 109 | -} | ||
| 110 | - | ||
| 111 | -func (s *Server) handleOAuthCallback(w http.ResponseWriter, r *http.Request) { | ||
| 112 | sessData, err := s.oauth.ProcessCallback(r.Context(), r.URL.Query()) | 80 | sessData, err := s.oauth.ProcessCallback(r.Context(), r.URL.Query()) |
| 113 | if err != nil { | 81 | if err != nil { |
| 114 | s.logger.Error("OAuth callback failed", "error", err) | 82 | s.logger.Error("OAuth callback failed", "error", err) |
| @@ -117,7 +85,6 @@ func (s *Server) handleOAuthCallback(w http.ResponseWriter, r *http.Request) { | |||
| 117 | } | 85 | } |
| 118 | 86 | ||
| 119 | did := sessData.AccountDID.String() | 87 | did := sessData.AccountDID.String() |
| 120 | - | ||
| 121 | client := s.pdsClientFromSession(sessData) | 88 | client := s.pdsClientFromSession(sessData) |
| 122 | 89 | ||
| 123 | user, err := s.dbs.Users.CreateUser(r.Context(), did) | 90 | user, err := s.dbs.Users.CreateUser(r.Context(), did) |
| @@ -139,15 +106,7 @@ func (s *Server) handleOAuthCallback(w http.ResponseWriter, r *http.Request) { | |||
| 139 | return | 106 | return |
| 140 | } | 107 | } |
| 141 | 108 | ||
| 142 | - http.SetCookie(w, &http.Cookie{ | 109 | + http.SetCookie(w, sessionCookie("glean_session", encoded, 86400*30, r)) |
| 143 | - Name: "glean_session", | ||
| 144 | - Value: encoded, | ||
| 145 | - Path: "/", | ||
| 146 | - MaxAge: 86400 * 30, | ||
| 147 | - HttpOnly: true, | ||
| 148 | - Secure: s.secureCookies, | ||
| 149 | - SameSite: http.SameSiteLaxMode, | ||
| 150 | - }) | ||
| 151 | 110 | ||
| 152 | s.syncUserInBackground(user.DID, client) | 111 | s.syncUserInBackground(user.DID, client) |
| 153 | 112 | ||
| @@ -187,6 +146,6 @@ func (s *Server) handleAuthLogout(w http.ResponseWriter, r *http.Request) { | |||
| 187 | _ = s.oauth.Logout(r.Context(), did, session.SessionID) | 146 | _ = s.oauth.Logout(r.Context(), did, session.SessionID) |
| 188 | } | 147 | } |
| 189 | } | 148 | } |
| 190 | - s.clearUserSession(w) | 149 | + s.clearUserSession(w, r) |
| 191 | writeJSON(w, http.StatusOK, redirectResponse{Redirect: "/"}) | 150 | writeJSON(w, http.StatusOK, redirectResponse{Redirect: "/"}) |
| 192 | } | 151 | } |
modified
internal/server/middleware.go +8 -30 | @@ -5,7 +5,6 @@ import ( | ||
| 5 | 5 | "crypto/rand" |
| 6 | 6 | "encoding/hex" |
| 7 | 7 | "net/http" |
| 8 | - "net/url" | |
| 9 | 8 | "strings" |
| 10 | 9 | "time" |
| 11 | 10 | |
| @@ -18,7 +17,7 @@ func (s *Server) sessionMiddleware(next http.Handler) http.Handler { | ||
| 18 | 17 | if user != nil { |
| 19 | 18 | data := s.getSessionData(r) |
| 20 | 19 | if data != nil && data.SessionID != "" && !s.isOAuthSessionValid(r.Context(), data) { |
| 21 | - s.clearUserSession(w) | |
| 20 | + s.clearUserSession(w, r) | |
| 22 | 21 | next.ServeHTTP(w, r) |
| 23 | 22 | return |
| 24 | 23 | } |
| @@ -60,7 +59,12 @@ func csrfToken() string { | ||
| 60 | 59 | |
| 61 | 60 | // csrfMiddleware enforces double-submit CSRF. The token is issued in a readable |
| 62 | 61 | // cookie (glean_csrf) and must be echoed back via the X-CSRF-Token header or |
| 63 | -// csrf_token form field on every state-changing request. | |
| 62 | +// csrf_token form field on every state-changing request. The cookie is | |
| 63 | +// SameSite=Lax, so a cross-site request can't both carry it and read it to forge | |
| 64 | +// the header; that is the CSRF boundary. We deliberately do not check the | |
| 65 | +// Origin header here: behind the Caddy → SvelteKit → Go proxy chain the Host and | |
| 66 | +// Origin headers are rewritten in ways that make a same-origin comparison | |
| 67 | +// unreliable, and the double-submit token already provides the protection. | |
| 64 | 68 | func (s *Server) csrfMiddleware(next http.Handler) http.Handler { |
| 65 | 69 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 66 | 70 | if r.Method == http.MethodGet || r.Method == http.MethodHead || r.Method == http.MethodOptions { |
| @@ -69,9 +73,8 @@ func (s *Server) csrfMiddleware(next http.Handler) http.Handler { | ||
| 69 | 73 | Name: "glean_csrf", |
| 70 | 74 | Value: csrfToken(), |
| 71 | 75 | Path: "/", |
| 72 | - MaxAge: 86400, | |
| 76 | + MaxAge: 86400 * 30, | |
| 73 | 77 | HttpOnly: false, |
| 74 | - Secure: s.secureCookies, | |
| 75 | 78 | SameSite: http.SameSiteLaxMode, |
| 76 | 79 | }) |
| 77 | 80 | } |
| @@ -79,12 +82,6 @@ func (s *Server) csrfMiddleware(next http.Handler) http.Handler { | ||
| 79 | 82 | return |
| 80 | 83 | } |
| 81 | 84 | |
| 82 | - // Origin must match the configured allowlist, not the proxied Host header. | |
| 83 | - if !s.originAllowed(r) { | |
| 84 | - writeAPIError(w, http.StatusForbidden, "forbidden") | |
| 85 | - return | |
| 86 | - } | |
| 87 | - | |
| 88 | 85 | cookie, err := r.Cookie("glean_csrf") |
| 89 | 86 | if err != nil { |
| 90 | 87 | writeAPIError(w, http.StatusForbidden, "missing csrf token") |
| @@ -102,25 +99,6 @@ func (s *Server) csrfMiddleware(next http.Handler) http.Handler { | ||
| 102 | 99 | }) |
| 103 | 100 | } |
| 104 | 101 | |
| 105 | -func (s *Server) originAllowed(r *http.Request) bool { | |
| 106 | - origin := r.Header.Get("Origin") | |
| 107 | - if origin == "" { | |
| 108 | - return true // non-browser client | |
| 109 | - } | |
| 110 | - if s.allowedOrigin != "" { | |
| 111 | - return origin == s.allowedOrigin | |
| 112 | - } | |
| 113 | - return sameOrigin(origin, r.Host) | |
| 114 | -} | |
| 115 | - | |
| 116 | -func sameOrigin(origin, host string) bool { | |
| 117 | - u, err := url.Parse(origin) | |
| 118 | - if err != nil { | |
| 119 | - return false | |
| 120 | - } | |
| 121 | - return u.Host == host | |
| 122 | -} | |
| 123 | - | |
| 124 | 102 | func (s *Server) realIPLogger(next http.Handler) http.Handler { |
| 125 | 103 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 126 | 104 | start := time.Now() |
| @@ -5,7 +5,6 @@ import ( | |||
| 5 | "crypto/rand" | 5 | "crypto/rand" |
| 6 | "encoding/hex" | 6 | "encoding/hex" |
| 7 | "net/http" | 7 | "net/http" |
| 8 | - "net/url" | ||
| 9 | "strings" | 8 | "strings" |
| 10 | "time" | 9 | "time" |
| 11 | 10 | ||
| @@ -18,7 +17,7 @@ func (s *Server) sessionMiddleware(next http.Handler) http.Handler { | |||
| 18 | if user != nil { | 17 | if user != nil { |
| 19 | data := s.getSessionData(r) | 18 | data := s.getSessionData(r) |
| 20 | if data != nil && data.SessionID != "" && !s.isOAuthSessionValid(r.Context(), data) { | 19 | if data != nil && data.SessionID != "" && !s.isOAuthSessionValid(r.Context(), data) { |
| 21 | - s.clearUserSession(w) | 20 | + s.clearUserSession(w, r) |
| 22 | next.ServeHTTP(w, r) | 21 | next.ServeHTTP(w, r) |
| 23 | return | 22 | return |
| 24 | } | 23 | } |
| @@ -60,7 +59,12 @@ func csrfToken() string { | |||
| 60 | 59 | ||
| 61 | // csrfMiddleware enforces double-submit CSRF. The token is issued in a readable | 60 | // csrfMiddleware enforces double-submit CSRF. The token is issued in a readable |
| 62 | // cookie (glean_csrf) and must be echoed back via the X-CSRF-Token header or | 61 | // cookie (glean_csrf) and must be echoed back via the X-CSRF-Token header or |
| 63 | -// csrf_token form field on every state-changing request. | 62 | +// csrf_token form field on every state-changing request. The cookie is |
| 63 | +// SameSite=Lax, so a cross-site request can't both carry it and read it to forge | ||
| 64 | +// the header; that is the CSRF boundary. We deliberately do not check the | ||
| 65 | +// Origin header here: behind the Caddy → SvelteKit → Go proxy chain the Host and | ||
| 66 | +// Origin headers are rewritten in ways that make a same-origin comparison | ||
| 67 | +// unreliable, and the double-submit token already provides the protection. | ||
| 64 | func (s *Server) csrfMiddleware(next http.Handler) http.Handler { | 68 | func (s *Server) csrfMiddleware(next http.Handler) http.Handler { |
| 65 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | 69 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 66 | if r.Method == http.MethodGet || r.Method == http.MethodHead || r.Method == http.MethodOptions { | 70 | if r.Method == http.MethodGet || r.Method == http.MethodHead || r.Method == http.MethodOptions { |
| @@ -69,9 +73,8 @@ func (s *Server) csrfMiddleware(next http.Handler) http.Handler { | |||
| 69 | Name: "glean_csrf", | 73 | Name: "glean_csrf", |
| 70 | Value: csrfToken(), | 74 | Value: csrfToken(), |
| 71 | Path: "/", | 75 | Path: "/", |
| 72 | - MaxAge: 86400, | 76 | + MaxAge: 86400 * 30, |
| 73 | HttpOnly: false, | 77 | HttpOnly: false, |
| 74 | - Secure: s.secureCookies, | ||
| 75 | SameSite: http.SameSiteLaxMode, | 78 | SameSite: http.SameSiteLaxMode, |
| 76 | }) | 79 | }) |
| 77 | } | 80 | } |
| @@ -79,12 +82,6 @@ func (s *Server) csrfMiddleware(next http.Handler) http.Handler { | |||
| 79 | return | 82 | return |
| 80 | } | 83 | } |
| 81 | 84 | ||
| 82 | - // Origin must match the configured allowlist, not the proxied Host header. | ||
| 83 | - if !s.originAllowed(r) { | ||
| 84 | - writeAPIError(w, http.StatusForbidden, "forbidden") | ||
| 85 | - return | ||
| 86 | - } | ||
| 87 | - | ||
| 88 | cookie, err := r.Cookie("glean_csrf") | 85 | cookie, err := r.Cookie("glean_csrf") |
| 89 | if err != nil { | 86 | if err != nil { |
| 90 | writeAPIError(w, http.StatusForbidden, "missing csrf token") | 87 | writeAPIError(w, http.StatusForbidden, "missing csrf token") |
| @@ -102,25 +99,6 @@ func (s *Server) csrfMiddleware(next http.Handler) http.Handler { | |||
| 102 | }) | 99 | }) |
| 103 | } | 100 | } |
| 104 | 101 | ||
| 105 | -func (s *Server) originAllowed(r *http.Request) bool { | ||
| 106 | - origin := r.Header.Get("Origin") | ||
| 107 | - if origin == "" { | ||
| 108 | - return true // non-browser client | ||
| 109 | - } | ||
| 110 | - if s.allowedOrigin != "" { | ||
| 111 | - return origin == s.allowedOrigin | ||
| 112 | - } | ||
| 113 | - return sameOrigin(origin, r.Host) | ||
| 114 | -} | ||
| 115 | - | ||
| 116 | -func sameOrigin(origin, host string) bool { | ||
| 117 | - u, err := url.Parse(origin) | ||
| 118 | - if err != nil { | ||
| 119 | - return false | ||
| 120 | - } | ||
| 121 | - return u.Host == host | ||
| 122 | -} | ||
| 123 | - | ||
| 124 | func (s *Server) realIPLogger(next http.Handler) http.Handler { | 102 | func (s *Server) realIPLogger(next http.Handler) http.Handler { |
| 125 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { | 103 | return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { |
| 126 | start := time.Now() | 104 | start := time.Now() |
modified
internal/server/server.go +53 -71 | @@ -48,28 +48,25 @@ var oauthScopes = []string{ | ||
| 48 | 48 | } |
| 49 | 49 | |
| 50 | 50 | type Server struct { |
| 51 | - dbs *db.Store | |
| 52 | - router *chi.Mux | |
| 53 | - logger *slog.Logger | |
| 54 | - oauth *oauth.ClientApp | |
| 55 | - oauthStore *db.OAuthStore | |
| 56 | - fetcher *feed.Fetcher | |
| 57 | - scheduler *feed.Scheduler | |
| 58 | - engine *cluster.Engine | |
| 59 | - feedback *feedback.Service | |
| 60 | - scraper *scraper.Scraper | |
| 61 | - llm ml.TextModel | |
| 62 | - clientID string | |
| 63 | - callbackURL string | |
| 64 | - frontendURL string | |
| 65 | - sessionKey []byte | |
| 66 | - secureCookies bool // true in production (clientID set); gates cookie Secure flag | |
| 67 | - allowedOrigin string // configured browser origin for CSRF; empty in localhost dev | |
| 51 | + dbs *db.Store | |
| 52 | + router *chi.Mux | |
| 53 | + logger *slog.Logger | |
| 54 | + oauth *oauth.ClientApp | |
| 55 | + oauthStore *db.OAuthStore | |
| 56 | + fetcher *feed.Fetcher | |
| 57 | + scheduler *feed.Scheduler | |
| 58 | + engine *cluster.Engine | |
| 59 | + feedback *feedback.Service | |
| 60 | + scraper *scraper.Scraper | |
| 61 | + llm ml.TextModel | |
| 62 | + clientID string // empty in localhost dev; toggles the OAuth flow mode | |
| 63 | + frontendURL string // public browser origin (e.g. https://glean.at) | |
| 64 | + sessionKey []byte | |
| 68 | 65 | } |
| 69 | 66 | |
| 70 | 67 | func New( |
| 71 | 68 | dbs *db.Store, |
| 72 | - clientID, callbackURL, frontendURL string, | |
| 69 | + clientID, frontendURL string, | |
| 73 | 70 | scheduler *feed.Scheduler, |
| 74 | 71 | fetcher *feed.Fetcher, |
| 75 | 72 | engine *cluster.Engine, |
| @@ -79,45 +76,33 @@ func New( | ||
| 79 | 76 | ) *Server { |
| 80 | 77 | oauthStore := db.NewOAuthStore(dbs) |
| 81 | 78 | |
| 79 | + // The OAuth callback is always served by the frontend at /api/auth/callback | |
| 80 | + // (SvelteKit proxies it here). clientID empty = localhost dev flow. | |
| 81 | + callbackURL := strings.TrimRight(frontendURL, "/") + "/api/auth/callback" | |
| 82 | + | |
| 82 | 83 | var config oauth.ClientConfig |
| 83 | 84 | if clientID == "" { |
| 84 | - // Localhost dev: the OAuth callback must go through the SvelteKit | |
| 85 | - // frontend (which proxies /api to Go) so the post-callback redirect to | |
| 86 | - // /dashboard lands on the frontend, not on Go's API-only server. | |
| 87 | - origin := frontendURL | |
| 88 | - if origin == "" { | |
| 89 | - origin = "http://localhost:3000" | |
| 90 | - } | |
| 91 | - cbURL := strings.TrimRight(origin, "/") + "/api/auth/callback" | |
| 92 | - config = oauth.NewLocalhostConfig(cbURL, oauthScopes) | |
| 85 | + config = oauth.NewLocalhostConfig(callbackURL, oauthScopes) | |
| 93 | 86 | } else { |
| 94 | - // callbackURL points at the public SvelteKit origin, proxied to /api/auth/callback. | |
| 95 | - cb := callbackURL | |
| 96 | - if !strings.Contains(cb, "/api/auth/callback") { | |
| 97 | - cb = strings.TrimRight(cb, "/") + "/api/auth/callback" | |
| 98 | - } | |
| 99 | - config = oauth.NewPublicConfig(clientID, cb, oauthScopes) | |
| 87 | + config = oauth.NewPublicConfig(clientID, callbackURL, oauthScopes) | |
| 100 | 88 | } |
| 101 | 89 | oauthClient := oauth.NewClientApp(&config, oauthStore) |
| 102 | 90 | |
| 103 | 91 | s := &Server{ |
| 104 | - dbs: dbs, | |
| 105 | - router: chi.NewMux(), | |
| 106 | - logger: logger, | |
| 107 | - oauth: oauthClient, | |
| 108 | - oauthStore: oauthStore, | |
| 109 | - fetcher: fetcher, | |
| 110 | - scheduler: scheduler, | |
| 111 | - engine: engine, | |
| 112 | - feedback: feedback.NewService(dbs.SQLDB()), | |
| 113 | - scraper: scraper.New(logger), | |
| 114 | - llm: textModel, | |
| 115 | - clientID: clientID, | |
| 116 | - callbackURL: callbackURL, | |
| 117 | - frontendURL: frontendURL, | |
| 118 | - sessionKey: sessionKey, | |
| 119 | - secureCookies: clientID != "", | |
| 120 | - allowedOrigin: frontendOrigin(frontendURL, clientID), | |
| 92 | + dbs: dbs, | |
| 93 | + router: chi.NewMux(), | |
| 94 | + logger: logger, | |
| 95 | + oauth: oauthClient, | |
| 96 | + oauthStore: oauthStore, | |
| 97 | + fetcher: fetcher, | |
| 98 | + scheduler: scheduler, | |
| 99 | + engine: engine, | |
| 100 | + feedback: feedback.NewService(dbs.SQLDB()), | |
| 101 | + scraper: scraper.New(logger), | |
| 102 | + llm: textModel, | |
| 103 | + clientID: clientID, | |
| 104 | + frontendURL: frontendURL, | |
| 105 | + sessionKey: sessionKey, | |
| 121 | 106 | } |
| 122 | 107 | |
| 123 | 108 | s.setupMiddleware() |
| @@ -336,31 +321,28 @@ func (s *Server) handleNotFound(w http.ResponseWriter, r *http.Request) { | ||
| 336 | 321 | writeAPIError(w, http.StatusNotFound, "not found") |
| 337 | 322 | } |
| 338 | 323 | |
| 339 | -// allowedOrigins returns the browser origins permitted by CORS. In production | |
| 340 | -// this is the configured frontend URL; in localhost dev the SvelteKit proxy | |
| 341 | -// is same-origin so any value works, but we still avoid "*". | |
| 324 | +// allowedOrigins returns the browser origins permitted by CORS. The app is | |
| 325 | +// normally same-origin (SvelteKit proxies /api to Go); this mainly matters for | |
| 326 | +// the localhost dev server. | |
| 342 | 327 | func (s *Server) allowedOrigins() []string { |
| 343 | - if s.allowedOrigin != "" { | |
| 344 | - return []string{s.allowedOrigin} | |
| 328 | + if o := originOf(s.frontendURL); o != "" { | |
| 329 | + return []string{o} | |
| 345 | 330 | } |
| 346 | 331 | return []string{"http://localhost:3000", "http://localhost:5173"} |
| 347 | 332 | } |
| 348 | 333 | |
| 349 | -// frontendOrigin derives the browser origin (scheme://host) from frontendURL, | |
| 350 | -// falling back to the client ID host. | |
| 351 | -func frontendOrigin(frontendURL, clientID string) string { | |
| 352 | - for _, raw := range []string{frontendURL, clientID} { | |
| 353 | - if raw == "" { | |
| 354 | - continue | |
| 355 | - } | |
| 356 | - u, err := url.Parse(raw) | |
| 357 | - if err != nil || u.Host == "" { | |
| 358 | - continue | |
| 359 | - } | |
| 360 | - if u.Scheme == "" { | |
| 361 | - u.Scheme = "https" | |
| 362 | - } | |
| 363 | - return u.Scheme + "://" + u.Host | |
| 334 | +// originOf parses a URL and returns its scheme://host, defaulting an absent | |
| 335 | +// scheme to https. Returns "" if raw is empty or unparseable. | |
| 336 | +func originOf(raw string) string { | |
| 337 | + if raw == "" { | |
| 338 | + return "" | |
| 339 | + } | |
| 340 | + u, err := url.Parse(raw) | |
| 341 | + if err != nil || u.Host == "" { | |
| 342 | + return "" | |
| 343 | + } | |
| 344 | + if u.Scheme == "" { | |
| 345 | + u.Scheme = "https" | |
| 364 | 346 | } |
| 365 | - return "" | |
| 347 | + return u.Scheme + "://" + u.Host | |
| 366 | 348 | } |
| @@ -48,28 +48,25 @@ var oauthScopes = []string{ | |||
| 48 | } | 48 | } |
| 49 | 49 | ||
| 50 | type Server struct { | 50 | type Server struct { |
| 51 | - dbs *db.Store | 51 | + dbs *db.Store |
| 52 | - router *chi.Mux | 52 | + router *chi.Mux |
| 53 | - logger *slog.Logger | 53 | + logger *slog.Logger |
| 54 | - oauth *oauth.ClientApp | 54 | + oauth *oauth.ClientApp |
| 55 | - oauthStore *db.OAuthStore | 55 | + oauthStore *db.OAuthStore |
| 56 | - fetcher *feed.Fetcher | 56 | + fetcher *feed.Fetcher |
| 57 | - scheduler *feed.Scheduler | 57 | + scheduler *feed.Scheduler |
| 58 | - engine *cluster.Engine | 58 | + engine *cluster.Engine |
| 59 | - feedback *feedback.Service | 59 | + feedback *feedback.Service |
| 60 | - scraper *scraper.Scraper | 60 | + scraper *scraper.Scraper |
| 61 | - llm ml.TextModel | 61 | + llm ml.TextModel |
| 62 | - clientID string | 62 | + clientID string // empty in localhost dev; toggles the OAuth flow mode |
| 63 | - callbackURL string | 63 | + frontendURL string // public browser origin (e.g. https://glean.at) |
| 64 | - frontendURL string | 64 | + sessionKey []byte |
| 65 | - sessionKey []byte | ||
| 66 | - secureCookies bool // true in production (clientID set); gates cookie Secure flag | ||
| 67 | - allowedOrigin string // configured browser origin for CSRF; empty in localhost dev | ||
| 68 | } | 65 | } |
| 69 | 66 | ||
| 70 | func New( | 67 | func New( |
| 71 | dbs *db.Store, | 68 | dbs *db.Store, |
| 72 | - clientID, callbackURL, frontendURL string, | 69 | + clientID, frontendURL string, |
| 73 | scheduler *feed.Scheduler, | 70 | scheduler *feed.Scheduler, |
| 74 | fetcher *feed.Fetcher, | 71 | fetcher *feed.Fetcher, |
| 75 | engine *cluster.Engine, | 72 | engine *cluster.Engine, |
| @@ -79,45 +76,33 @@ func New( | |||
| 79 | ) *Server { | 76 | ) *Server { |
| 80 | oauthStore := db.NewOAuthStore(dbs) | 77 | oauthStore := db.NewOAuthStore(dbs) |
| 81 | 78 | ||
| 79 | + // The OAuth callback is always served by the frontend at /api/auth/callback | ||
| 80 | + // (SvelteKit proxies it here). clientID empty = localhost dev flow. | ||
| 81 | + callbackURL := strings.TrimRight(frontendURL, "/") + "/api/auth/callback" | ||
| 82 | + | ||
| 82 | var config oauth.ClientConfig | 83 | var config oauth.ClientConfig |
| 83 | if clientID == "" { | 84 | if clientID == "" { |
| 84 | - // Localhost dev: the OAuth callback must go through the SvelteKit | 85 | + config = oauth.NewLocalhostConfig(callbackURL, oauthScopes) |
| 85 | - // frontend (which proxies /api to Go) so the post-callback redirect to | ||
| 86 | - // /dashboard lands on the frontend, not on Go's API-only server. | ||
| 87 | - origin := frontendURL | ||
| 88 | - if origin == "" { | ||
| 89 | - origin = "http://localhost:3000" | ||
| 90 | - } | ||
| 91 | - cbURL := strings.TrimRight(origin, "/") + "/api/auth/callback" | ||
| 92 | - config = oauth.NewLocalhostConfig(cbURL, oauthScopes) | ||
| 93 | } else { | 86 | } else { |
| 94 | - // callbackURL points at the public SvelteKit origin, proxied to /api/auth/callback. | 87 | + config = oauth.NewPublicConfig(clientID, callbackURL, oauthScopes) |
| 95 | - cb := callbackURL | ||
| 96 | - if !strings.Contains(cb, "/api/auth/callback") { | ||
| 97 | - cb = strings.TrimRight(cb, "/") + "/api/auth/callback" | ||
| 98 | - } | ||
| 99 | - config = oauth.NewPublicConfig(clientID, cb, oauthScopes) | ||
| 100 | } | 88 | } |
| 101 | oauthClient := oauth.NewClientApp(&config, oauthStore) | 89 | oauthClient := oauth.NewClientApp(&config, oauthStore) |
| 102 | 90 | ||
| 103 | s := &Server{ | 91 | s := &Server{ |
| 104 | - dbs: dbs, | 92 | + dbs: dbs, |
| 105 | - router: chi.NewMux(), | 93 | + router: chi.NewMux(), |
| 106 | - logger: logger, | 94 | + logger: logger, |
| 107 | - oauth: oauthClient, | 95 | + oauth: oauthClient, |
| 108 | - oauthStore: oauthStore, | 96 | + oauthStore: oauthStore, |
| 109 | - fetcher: fetcher, | 97 | + fetcher: fetcher, |
| 110 | - scheduler: scheduler, | 98 | + scheduler: scheduler, |
| 111 | - engine: engine, | 99 | + engine: engine, |
| 112 | - feedback: feedback.NewService(dbs.SQLDB()), | 100 | + feedback: feedback.NewService(dbs.SQLDB()), |
| 113 | - scraper: scraper.New(logger), | 101 | + scraper: scraper.New(logger), |
| 114 | - llm: textModel, | 102 | + llm: textModel, |
| 115 | - clientID: clientID, | 103 | + clientID: clientID, |
| 116 | - callbackURL: callbackURL, | 104 | + frontendURL: frontendURL, |
| 117 | - frontendURL: frontendURL, | 105 | + sessionKey: sessionKey, |
| 118 | - sessionKey: sessionKey, | ||
| 119 | - secureCookies: clientID != "", | ||
| 120 | - allowedOrigin: frontendOrigin(frontendURL, clientID), | ||
| 121 | } | 106 | } |
| 122 | 107 | ||
| 123 | s.setupMiddleware() | 108 | s.setupMiddleware() |
| @@ -336,31 +321,28 @@ func (s *Server) handleNotFound(w http.ResponseWriter, r *http.Request) { | |||
| 336 | writeAPIError(w, http.StatusNotFound, "not found") | 321 | writeAPIError(w, http.StatusNotFound, "not found") |
| 337 | } | 322 | } |
| 338 | 323 | ||
| 339 | -// allowedOrigins returns the browser origins permitted by CORS. In production | 324 | +// allowedOrigins returns the browser origins permitted by CORS. The app is |
| 340 | -// this is the configured frontend URL; in localhost dev the SvelteKit proxy | 325 | +// normally same-origin (SvelteKit proxies /api to Go); this mainly matters for |
| 341 | -// is same-origin so any value works, but we still avoid "*". | 326 | +// the localhost dev server. |
| 342 | func (s *Server) allowedOrigins() []string { | 327 | func (s *Server) allowedOrigins() []string { |
| 343 | - if s.allowedOrigin != "" { | 328 | + if o := originOf(s.frontendURL); o != "" { |
| 344 | - return []string{s.allowedOrigin} | 329 | + return []string{o} |
| 345 | } | 330 | } |
| 346 | return []string{"http://localhost:3000", "http://localhost:5173"} | 331 | return []string{"http://localhost:3000", "http://localhost:5173"} |
| 347 | } | 332 | } |
| 348 | 333 | ||
| 349 | -// frontendOrigin derives the browser origin (scheme://host) from frontendURL, | 334 | +// originOf parses a URL and returns its scheme://host, defaulting an absent |
| 350 | -// falling back to the client ID host. | 335 | +// scheme to https. Returns "" if raw is empty or unparseable. |
| 351 | -func frontendOrigin(frontendURL, clientID string) string { | 336 | +func originOf(raw string) string { |
| 352 | - for _, raw := range []string{frontendURL, clientID} { | 337 | + if raw == "" { |
| 353 | - if raw == "" { | 338 | + return "" |
| 354 | - continue | 339 | + } |
| 355 | - } | 340 | + u, err := url.Parse(raw) |
| 356 | - u, err := url.Parse(raw) | 341 | + if err != nil || u.Host == "" { |
| 357 | - if err != nil || u.Host == "" { | 342 | + return "" |
| 358 | - continue | 343 | + } |
| 359 | - } | 344 | + if u.Scheme == "" { |
| 360 | - if u.Scheme == "" { | 345 | + u.Scheme = "https" |
| 361 | - u.Scheme = "https" | ||
| 362 | - } | ||
| 363 | - return u.Scheme + "://" + u.Host | ||
| 364 | } | 346 | } |
| 365 | - return "" | 347 | + return u.Scheme + "://" + u.Host |
| 366 | } | 348 | } |
modified
internal/server/session.go +20 -17 | @@ -46,7 +46,7 @@ func (s *Server) getUserFromSession(r *http.Request) *db.User { | ||
| 46 | 46 | return user |
| 47 | 47 | } |
| 48 | 48 | |
| 49 | -func (s *Server) setUserSession(w http.ResponseWriter, user *db.User) { | |
| 49 | +func (s *Server) setUserSession(w http.ResponseWriter, r *http.Request, user *db.User) { | |
| 50 | 50 | data := sessionData{DID: user.DID} |
| 51 | 51 | encoded, err := encodeSession(s.sessionKey, data) |
| 52 | 52 | if err != nil { |
| @@ -54,27 +54,30 @@ func (s *Server) setUserSession(w http.ResponseWriter, user *db.User) { | ||
| 54 | 54 | return |
| 55 | 55 | } |
| 56 | 56 | |
| 57 | - http.SetCookie(w, &http.Cookie{ | |
| 58 | - Name: "glean_session", | |
| 59 | - Value: encoded, | |
| 60 | - Path: "/", | |
| 61 | - MaxAge: 86400 * 30, | |
| 62 | - HttpOnly: true, | |
| 63 | - Secure: s.secureCookies, | |
| 64 | - SameSite: http.SameSiteLaxMode, | |
| 65 | - }) | |
| 57 | + http.SetCookie(w, sessionCookie("glean_session", encoded, 86400*30, r)) | |
| 58 | +} | |
| 59 | + | |
| 60 | +func (s *Server) clearUserSession(w http.ResponseWriter, r *http.Request) { | |
| 61 | + http.SetCookie(w, sessionCookie("glean_session", "", -1, r)) | |
| 66 | 62 | } |
| 67 | 63 | |
| 68 | -func (s *Server) clearUserSession(w http.ResponseWriter) { | |
| 69 | - http.SetCookie(w, &http.Cookie{ | |
| 70 | - Name: "glean_session", | |
| 71 | - Value: "", | |
| 64 | +// sessionCookie builds the session cookie. Secure is set when the request | |
| 65 | +// arrived over TLS or a TLS-terminating proxy (X-Forwarded-Proto: https), so | |
| 66 | +// it is correct in production behind Caddy and off in plain-HTTP local dev. | |
| 67 | +func sessionCookie(name, value string, maxAge int, r *http.Request) *http.Cookie { | |
| 68 | + return &http.Cookie{ | |
| 69 | + Name: name, | |
| 70 | + Value: value, | |
| 72 | 71 | Path: "/", |
| 73 | - MaxAge: -1, | |
| 72 | + MaxAge: maxAge, | |
| 74 | 73 | HttpOnly: true, |
| 75 | - Secure: s.secureCookies, | |
| 74 | + Secure: isHTTPS(r), | |
| 76 | 75 | SameSite: http.SameSiteLaxMode, |
| 77 | - }) | |
| 76 | + } | |
| 77 | +} | |
| 78 | + | |
| 79 | +func isHTTPS(r *http.Request) bool { | |
| 80 | + return r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" | |
| 78 | 81 | } |
| 79 | 82 | |
| 80 | 83 | type sessionData struct { |
| @@ -46,7 +46,7 @@ func (s *Server) getUserFromSession(r *http.Request) *db.User { | |||
| 46 | return user | 46 | return user |
| 47 | } | 47 | } |
| 48 | 48 | ||
| 49 | -func (s *Server) setUserSession(w http.ResponseWriter, user *db.User) { | 49 | +func (s *Server) setUserSession(w http.ResponseWriter, r *http.Request, user *db.User) { |
| 50 | data := sessionData{DID: user.DID} | 50 | data := sessionData{DID: user.DID} |
| 51 | encoded, err := encodeSession(s.sessionKey, data) | 51 | encoded, err := encodeSession(s.sessionKey, data) |
| 52 | if err != nil { | 52 | if err != nil { |
| @@ -54,27 +54,30 @@ func (s *Server) setUserSession(w http.ResponseWriter, user *db.User) { | |||
| 54 | return | 54 | return |
| 55 | } | 55 | } |
| 56 | 56 | ||
| 57 | - http.SetCookie(w, &http.Cookie{ | 57 | + http.SetCookie(w, sessionCookie("glean_session", encoded, 86400*30, r)) |
| 58 | - Name: "glean_session", | 58 | +} |
| 59 | - Value: encoded, | 59 | + |
| 60 | - Path: "/", | 60 | +func (s *Server) clearUserSession(w http.ResponseWriter, r *http.Request) { |
| 61 | - MaxAge: 86400 * 30, | 61 | + http.SetCookie(w, sessionCookie("glean_session", "", -1, r)) |
| 62 | - HttpOnly: true, | ||
| 63 | - Secure: s.secureCookies, | ||
| 64 | - SameSite: http.SameSiteLaxMode, | ||
| 65 | - }) | ||
| 66 | } | 62 | } |
| 67 | 63 | ||
| 68 | -func (s *Server) clearUserSession(w http.ResponseWriter) { | 64 | +// sessionCookie builds the session cookie. Secure is set when the request |
| 69 | - http.SetCookie(w, &http.Cookie{ | 65 | +// arrived over TLS or a TLS-terminating proxy (X-Forwarded-Proto: https), so |
| 70 | - Name: "glean_session", | 66 | +// it is correct in production behind Caddy and off in plain-HTTP local dev. |
| 71 | - Value: "", | 67 | +func sessionCookie(name, value string, maxAge int, r *http.Request) *http.Cookie { |
| 68 | + return &http.Cookie{ | ||
| 69 | + Name: name, | ||
| 70 | + Value: value, | ||
| 72 | Path: "/", | 71 | Path: "/", |
| 73 | - MaxAge: -1, | 72 | + MaxAge: maxAge, |
| 74 | HttpOnly: true, | 73 | HttpOnly: true, |
| 75 | - Secure: s.secureCookies, | 74 | + Secure: isHTTPS(r), |
| 76 | SameSite: http.SameSiteLaxMode, | 75 | SameSite: http.SameSiteLaxMode, |
| 77 | - }) | 76 | + } |
| 77 | +} | ||
| 78 | + | ||
| 79 | +func isHTTPS(r *http.Request) bool { | ||
| 80 | + return r.TLS != nil || r.Header.Get("X-Forwarded-Proto") == "https" | ||
| 78 | } | 81 | } |
| 79 | 82 | ||
| 80 | type sessionData struct { | 83 | type sessionData struct { |
modified
main.go +6 -3 | @@ -69,8 +69,11 @@ func main() { | ||
| 69 | 69 | defer dbs.Close() |
| 70 | 70 | |
| 71 | 71 | clientID := envOr("GLEAN_OAUTH_CLIENT_ID", "") |
| 72 | - callbackURL := envOr("GLEAN_OAUTH_REDIRECT_URL", "") | |
| 73 | - frontendURL := envOr("GLEAN_FRONTEND_URL", "http://localhost:3000") | |
| 72 | + frontendURL := envOr("GLEAN_FRONTEND_URL", "") | |
| 73 | + if frontendURL == "" { | |
| 74 | + fmt.Fprintln(os.Stderr, "GLEAN_FRONTEND_URL is required (the public browser origin, e.g. https://glean.at)") | |
| 75 | + os.Exit(1) | |
| 76 | + } | |
| 74 | 77 | |
| 75 | 78 | storeAdapter := db.NewFeedAdapter(dbs.Articles) |
| 76 | 79 | siteFetcher := atproto.NewStandardSiteFetcher(logger) |
| @@ -105,7 +108,7 @@ func main() { | ||
| 105 | 108 | engine := cluster.NewEngine(dbs.SQLDB(), dbs.Articles, embedder, llm, feedback.NewService(dbs.SQLDB()), logger, cluster.DefaultConfig()) |
| 106 | 109 | |
| 107 | 110 | fetcher := feed.NewFetcher(siteFetcher) |
| 108 | - srv := server.New(dbs, clientID, callbackURL, frontendURL, scheduler, fetcher, engine, logger, []byte(sessionKey), llm) | |
| 111 | + srv := server.New(dbs, clientID, frontendURL, scheduler, fetcher, engine, logger, []byte(sessionKey), llm) | |
| 109 | 112 | |
| 110 | 113 | cron := cluster.NewCron(engine, *clusterInterval, logger, dbs) |
| 111 | 114 | |
| @@ -69,8 +69,11 @@ func main() { | |||
| 69 | defer dbs.Close() | 69 | defer dbs.Close() |
| 70 | 70 | ||
| 71 | clientID := envOr("GLEAN_OAUTH_CLIENT_ID", "") | 71 | clientID := envOr("GLEAN_OAUTH_CLIENT_ID", "") |
| 72 | - callbackURL := envOr("GLEAN_OAUTH_REDIRECT_URL", "") | 72 | + frontendURL := envOr("GLEAN_FRONTEND_URL", "") |
| 73 | - frontendURL := envOr("GLEAN_FRONTEND_URL", "http://localhost:3000") | 73 | + if frontendURL == "" { |
| 74 | + fmt.Fprintln(os.Stderr, "GLEAN_FRONTEND_URL is required (the public browser origin, e.g. https://glean.at)") | ||
| 75 | + os.Exit(1) | ||
| 76 | + } | ||
| 74 | 77 | ||
| 75 | storeAdapter := db.NewFeedAdapter(dbs.Articles) | 78 | storeAdapter := db.NewFeedAdapter(dbs.Articles) |
| 76 | siteFetcher := atproto.NewStandardSiteFetcher(logger) | 79 | siteFetcher := atproto.NewStandardSiteFetcher(logger) |
| @@ -105,7 +108,7 @@ func main() { | |||
| 105 | engine := cluster.NewEngine(dbs.SQLDB(), dbs.Articles, embedder, llm, feedback.NewService(dbs.SQLDB()), logger, cluster.DefaultConfig()) | 108 | engine := cluster.NewEngine(dbs.SQLDB(), dbs.Articles, embedder, llm, feedback.NewService(dbs.SQLDB()), logger, cluster.DefaultConfig()) |
| 106 | 109 | ||
| 107 | fetcher := feed.NewFetcher(siteFetcher) | 110 | fetcher := feed.NewFetcher(siteFetcher) |
| 108 | - srv := server.New(dbs, clientID, callbackURL, frontendURL, scheduler, fetcher, engine, logger, []byte(sessionKey), llm) | 111 | + srv := server.New(dbs, clientID, frontendURL, scheduler, fetcher, engine, logger, []byte(sessionKey), llm) |
| 109 | 112 | ||
| 110 | cron := cluster.NewCron(engine, *clusterInterval, logger, dbs) | 113 | cron := cluster.NewCron(engine, *clusterInterval, logger, dbs) |
| 111 | 114 | ||
modified
readme.md +27 -26 | @@ -63,39 +63,40 @@ Then open `http://localhost:3000`. | ||
| 63 | 63 | |
| 64 | 64 | ## Configuration |
| 65 | 65 | |
| 66 | -| Variable | Default | What it does | | |
| 67 | -| ---------------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------- | | |
| 68 | -| `GLEAN_SESSION_KEY` | _(required)_ | Secret key for signing session cookies (any random string) | | |
| 69 | -| `GLEAN_ADDR` | `:8080` | Listen address | | |
| 70 | -| `GLEAN_DB` | `glean.db` | SQLite base path (`_users`, `_articles`, `_recs` suffixes) | | |
| 71 | -| `GLEAN_JETSTREAM` | `wss://jetstream1.eurosky.network` | Jetstream WebSocket URL | | |
| 72 | -| `GLEAN_SYNC_INTERVAL` | `8h` | PDS sync interval (Go duration: `24h`, `12h`, etc.) | | |
| 73 | -| `GLEAN_CLUSTER_INTERVAL` | `1h` | Cluster recomputation interval (Go duration) | | |
| 74 | -| `GLEAN_FETCH_INTERVAL` | `15m` | Feed fetch scheduler tick interval (Go duration) | | |
| 75 | -| `GLEAN_COLLECTION_DIR_URL` | _(empty)_ | Collection directory URL for startup backfill | | |
| 76 | -| `GLEAN_BACKFILL_CONCURRENCY` | `5` | Max concurrent backfill workers | | |
| 77 | -| `GLEAN_PLC_URL` | `https://plc.eurosky.network` | PLC directory URL for DID resolution | | |
| 78 | -| `GLEAN_OAUTH_CLIENT_ID` | _(empty)_ | OAuth client metadata URL (leave empty for localhost dev) | | |
| 79 | -| `GLEAN_OAUTH_REDIRECT_URL` | _(empty)_ | OAuth redirect URL, must resolve to `/api/auth/callback` on the public origin (leave empty for localhost dev) | | |
| 80 | -| `GLEAN_FRONTEND_URL` | `http://localhost:3000` | Public origin of the SvelteKit frontend; used as the OAuth callback base in localhost dev | | |
| 81 | -| `GLEAN_EMBED_BASE_URL` | _(empty)_ | Embeddings API base URL (recommended, see below) | | |
| 82 | -| `GLEAN_EMBED_API_KEY` | _(empty)_ | API key for the embeddings endpoint | | |
| 83 | -| `GLEAN_EMBED_MODEL` | `text-embedding-3-small` | Embedding model name | | |
| 84 | -| `GLEAN_EMBED_DIMENSION` | `1536` | Embedding vector dimension | | |
| 85 | -| `GLEAN_LLM_BASE_URL` | _(empty)_ | LLM API base URL for language detection and digest summaries (see below) | | |
| 86 | -| `GLEAN_LLM_API_KEY` | _(empty)_ | API key for the LLM endpoint | | |
| 87 | -| `GLEAN_LLM_MODEL` | `gpt-4o-mini` | LLM model name | | |
| 88 | -| `GLEAN_PPROF_ADDR` | _(empty)_ | Enable pprof profiling server (e.g. `:6060`, off by default) | | |
| 66 | +| Variable | Default | What it does | | |
| 67 | +| ---------------------------- | ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | | |
| 68 | +| `GLEAN_SESSION_KEY` | _(required)_ | Secret key for signing session cookies (any random string) | | |
| 69 | +| `GLEAN_ADDR` | `:8080` | Listen address | | |
| 70 | +| `GLEAN_DB` | `glean.db` | SQLite base path (`_users`, `_articles`, `_recs` suffixes) | | |
| 71 | +| `GLEAN_JETSTREAM` | `wss://jetstream1.eurosky.network` | Jetstream WebSocket URL | | |
| 72 | +| `GLEAN_SYNC_INTERVAL` | `8h` | PDS sync interval (Go duration: `24h`, `12h`, etc.) | | |
| 73 | +| `GLEAN_CLUSTER_INTERVAL` | `1h` | Cluster recomputation interval (Go duration) | | |
| 74 | +| `GLEAN_FETCH_INTERVAL` | `15m` | Feed fetch scheduler tick interval (Go duration) | | |
| 75 | +| `GLEAN_COLLECTION_DIR_URL` | _(empty)_ | Collection directory URL for startup backfill | | |
| 76 | +| `GLEAN_BACKFILL_CONCURRENCY` | `5` | Max concurrent backfill workers | | |
| 77 | +| `GLEAN_PLC_URL` | `https://plc.eurosky.network` | PLC directory URL for DID resolution | | |
| 78 | +| `GLEAN_OAUTH_CLIENT_ID` | _(empty)_ | OAuth client-metadata URL; enables production OAuth (leave empty for localhost dev). Must resolve to this server's `/api/oauth/client-metadata` | | |
| 79 | +| `GLEAN_FRONTEND_URL` | _(required)_ | Public origin of the SvelteKit frontend (e.g. `https://glean.at`); `make dev` defaults this to `http://localhost:3000` | | |
| 80 | +| `GLEAN_EMBED_BASE_URL` | _(empty)_ | Embeddings API base URL (recommended, see below) | | |
| 81 | +| `GLEAN_EMBED_API_KEY` | _(empty)_ | API key for the embeddings endpoint | | |
| 82 | +| `GLEAN_EMBED_MODEL` | `text-embedding-3-small` | Embedding model name | | |
| 83 | +| `GLEAN_EMBED_DIMENSION` | `1536` | Embedding vector dimension | | |
| 84 | +| `GLEAN_LLM_BASE_URL` | _(empty)_ | LLM API base URL for language detection and digest summaries (see below) | | |
| 85 | +| `GLEAN_LLM_API_KEY` | _(empty)_ | API key for the LLM endpoint | | |
| 86 | +| `GLEAN_LLM_MODEL` | `gpt-4o-mini` | LLM model name | | |
| 87 | +| `GLEAN_PPROF_ADDR` | _(empty)_ | Enable pprof profiling server (e.g. `:6060`, off by default) | | |
| 89 | 88 | |
| 90 | 89 | For production: |
| 91 | 90 | |
| 92 | 91 | ```bash |
| 93 | -export GLEAN_OAUTH_CLIENT_ID=https://yourdomain.com/oauth/client-metadata | |
| 94 | -export GLEAN_OAUTH_REDIRECT_URL=https://yourdomain.com/api/auth/callback | |
| 92 | +export GLEAN_OAUTH_CLIENT_ID=https://yourdomain.com/api/oauth/client-metadata | |
| 95 | 93 | ``` |
| 96 | 94 | |
| 95 | +The OAuth callback is always served at `$GLEAN_FRONTEND_URL/api/auth/callback` (the frontend proxies it to the backend), so there is no separate redirect-URL setting. | |
| 96 | + | |
| 97 | 97 | The SvelteKit server reads `GLEAN_API_URL` (default `http://localhost:8080`) to |
| 98 | -know where the Go API is, and `ORIGIN`/`PORT` for its own listen address. | |
| 98 | +find the Go API and `PORT` for its listen address. The public origin lives in | |
| 99 | +`GLEAN_FRONTEND_URL`; SvelteKit's adapter-node `ORIGIN` is not needed here. | |
| 99 | 100 | |
| 100 | 101 | ## Documentation |
| 101 | 102 | |
| @@ -63,39 +63,40 @@ Then open `http://localhost:3000`. | |||
| 63 | 63 | ||
| 64 | ## Configuration | 64 | ## Configuration |
| 65 | 65 | ||
| 66 | -| Variable | Default | What it does | | 66 | +| Variable | Default | What it does | |
| 67 | -| ---------------------------- | ---------------------------------- | ------------------------------------------------------------------------------------------------------------- | | 67 | +| ---------------------------- | ---------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------- | |
| 68 | -| `GLEAN_SESSION_KEY` | _(required)_ | Secret key for signing session cookies (any random string) | | 68 | +| `GLEAN_SESSION_KEY` | _(required)_ | Secret key for signing session cookies (any random string) | |
| 69 | -| `GLEAN_ADDR` | `:8080` | Listen address | | 69 | +| `GLEAN_ADDR` | `:8080` | Listen address | |
| 70 | -| `GLEAN_DB` | `glean.db` | SQLite base path (`_users`, `_articles`, `_recs` suffixes) | | 70 | +| `GLEAN_DB` | `glean.db` | SQLite base path (`_users`, `_articles`, `_recs` suffixes) | |
| 71 | -| `GLEAN_JETSTREAM` | `wss://jetstream1.eurosky.network` | Jetstream WebSocket URL | | 71 | +| `GLEAN_JETSTREAM` | `wss://jetstream1.eurosky.network` | Jetstream WebSocket URL | |
| 72 | -| `GLEAN_SYNC_INTERVAL` | `8h` | PDS sync interval (Go duration: `24h`, `12h`, etc.) | | 72 | +| `GLEAN_SYNC_INTERVAL` | `8h` | PDS sync interval (Go duration: `24h`, `12h`, etc.) | |
| 73 | -| `GLEAN_CLUSTER_INTERVAL` | `1h` | Cluster recomputation interval (Go duration) | | 73 | +| `GLEAN_CLUSTER_INTERVAL` | `1h` | Cluster recomputation interval (Go duration) | |
| 74 | -| `GLEAN_FETCH_INTERVAL` | `15m` | Feed fetch scheduler tick interval (Go duration) | | 74 | +| `GLEAN_FETCH_INTERVAL` | `15m` | Feed fetch scheduler tick interval (Go duration) | |
| 75 | -| `GLEAN_COLLECTION_DIR_URL` | _(empty)_ | Collection directory URL for startup backfill | | 75 | +| `GLEAN_COLLECTION_DIR_URL` | _(empty)_ | Collection directory URL for startup backfill | |
| 76 | -| `GLEAN_BACKFILL_CONCURRENCY` | `5` | Max concurrent backfill workers | | 76 | +| `GLEAN_BACKFILL_CONCURRENCY` | `5` | Max concurrent backfill workers | |
| 77 | -| `GLEAN_PLC_URL` | `https://plc.eurosky.network` | PLC directory URL for DID resolution | | 77 | +| `GLEAN_PLC_URL` | `https://plc.eurosky.network` | PLC directory URL for DID resolution | |
| 78 | -| `GLEAN_OAUTH_CLIENT_ID` | _(empty)_ | OAuth client metadata URL (leave empty for localhost dev) | | 78 | +| `GLEAN_OAUTH_CLIENT_ID` | _(empty)_ | OAuth client-metadata URL; enables production OAuth (leave empty for localhost dev). Must resolve to this server's `/api/oauth/client-metadata` | |
| 79 | -| `GLEAN_OAUTH_REDIRECT_URL` | _(empty)_ | OAuth redirect URL, must resolve to `/api/auth/callback` on the public origin (leave empty for localhost dev) | | 79 | +| `GLEAN_FRONTEND_URL` | _(required)_ | Public origin of the SvelteKit frontend (e.g. `https://glean.at`); `make dev` defaults this to `http://localhost:3000` | |
| 80 | -| `GLEAN_FRONTEND_URL` | `http://localhost:3000` | Public origin of the SvelteKit frontend; used as the OAuth callback base in localhost dev | | 80 | +| `GLEAN_EMBED_BASE_URL` | _(empty)_ | Embeddings API base URL (recommended, see below) | |
| 81 | -| `GLEAN_EMBED_BASE_URL` | _(empty)_ | Embeddings API base URL (recommended, see below) | | 81 | +| `GLEAN_EMBED_API_KEY` | _(empty)_ | API key for the embeddings endpoint | |
| 82 | -| `GLEAN_EMBED_API_KEY` | _(empty)_ | API key for the embeddings endpoint | | 82 | +| `GLEAN_EMBED_MODEL` | `text-embedding-3-small` | Embedding model name | |
| 83 | -| `GLEAN_EMBED_MODEL` | `text-embedding-3-small` | Embedding model name | | 83 | +| `GLEAN_EMBED_DIMENSION` | `1536` | Embedding vector dimension | |
| 84 | -| `GLEAN_EMBED_DIMENSION` | `1536` | Embedding vector dimension | | 84 | +| `GLEAN_LLM_BASE_URL` | _(empty)_ | LLM API base URL for language detection and digest summaries (see below) | |
| 85 | -| `GLEAN_LLM_BASE_URL` | _(empty)_ | LLM API base URL for language detection and digest summaries (see below) | | 85 | +| `GLEAN_LLM_API_KEY` | _(empty)_ | API key for the LLM endpoint | |
| 86 | -| `GLEAN_LLM_API_KEY` | _(empty)_ | API key for the LLM endpoint | | 86 | +| `GLEAN_LLM_MODEL` | `gpt-4o-mini` | LLM model name | |
| 87 | -| `GLEAN_LLM_MODEL` | `gpt-4o-mini` | LLM model name | | 87 | +| `GLEAN_PPROF_ADDR` | _(empty)_ | Enable pprof profiling server (e.g. `:6060`, off by default) | |
| 88 | -| `GLEAN_PPROF_ADDR` | _(empty)_ | Enable pprof profiling server (e.g. `:6060`, off by default) | | ||
| 89 | 88 | ||
| 90 | For production: | 89 | For production: |
| 91 | 90 | ||
| 92 | ```bash | 91 | ```bash |
| 93 | -export GLEAN_OAUTH_CLIENT_ID=https://yourdomain.com/oauth/client-metadata | 92 | +export GLEAN_OAUTH_CLIENT_ID=https://yourdomain.com/api/oauth/client-metadata |
| 94 | -export GLEAN_OAUTH_REDIRECT_URL=https://yourdomain.com/api/auth/callback | ||
| 95 | ``` | 93 | ``` |
| 96 | 94 | ||
| 95 | +The OAuth callback is always served at `$GLEAN_FRONTEND_URL/api/auth/callback` (the frontend proxies it to the backend), so there is no separate redirect-URL setting. | ||
| 96 | + | ||
| 97 | The SvelteKit server reads `GLEAN_API_URL` (default `http://localhost:8080`) to | 97 | The SvelteKit server reads `GLEAN_API_URL` (default `http://localhost:8080`) to |
| 98 | -know where the Go API is, and `ORIGIN`/`PORT` for its own listen address. | 98 | +find the Go API and `PORT` for its listen address. The public origin lives in |
| 99 | +`GLEAN_FRONTEND_URL`; SvelteKit's adapter-node `ORIGIN` is not needed here. | ||
| 99 | 100 | ||
| 100 | ## Documentation | 101 | ## Documentation |
| 101 | 102 | ||
modified
web/src/hooks.server.ts +7 -10 | @@ -12,16 +12,6 @@ async function proxyApi({ | ||
| 12 | 12 | const target = API_URL.replace(/\/$/, "") + url.pathname + url.search; |
| 13 | 13 | |
| 14 | 14 | const headers = new Headers(request.headers); |
| 15 | - // Forward the browser's original Host so the Go CSRF same-origin check | |
| 16 | - // (Origin vs Host) passes. Go routes by path, not Host. | |
| 17 | - const originHost = request.headers.get("host"); | |
| 18 | - if (originHost) { | |
| 19 | - headers.set("host", originHost); | |
| 20 | - headers.set("x-forwarded-host", originHost); | |
| 21 | - } | |
| 22 | - const proto = url.protocol.replace(":", ""); | |
| 23 | - headers.set("x-forwarded-proto", proto); | |
| 24 | - | |
| 25 | 15 | const init: RequestInit = { |
| 26 | 16 | method: request.method, |
| 27 | 17 | headers, |
| @@ -29,6 +19,13 @@ async function proxyApi({ | ||
| 29 | 19 | request.method !== "GET" && request.method !== "HEAD" |
| 30 | 20 | ? await request.arrayBuffer() |
| 31 | 21 | : undefined, |
| 22 | + // The OAuth callback (/api/auth/callback) is a browser navigation, not an | |
| 23 | + // XHR: the auth server redirects the user's browser here, and Go responds | |
| 24 | + // with a 303 to a frontend route (/dashboard, /auth/login). Those routes | |
| 25 | + // only exist on the SvelteKit origin, not the Go API, so the proxy must | |
| 26 | + // NOT follow redirects server-side — pass the 303 through for the browser | |
| 27 | + // to follow via the normal Caddy → SvelteKit chain. | |
| 28 | + redirect: "manual", | |
| 32 | 29 | // @ts-expect-error Node fetch supports duplex for streaming request bodies. |
| 33 | 30 | duplex: "half", |
| 34 | 31 | }; |
| @@ -12,16 +12,6 @@ async function proxyApi({ | |||
| 12 | const target = API_URL.replace(/\/$/, "") + url.pathname + url.search; | 12 | const target = API_URL.replace(/\/$/, "") + url.pathname + url.search; |
| 13 | 13 | ||
| 14 | const headers = new Headers(request.headers); | 14 | const headers = new Headers(request.headers); |
| 15 | - // Forward the browser's original Host so the Go CSRF same-origin check | ||
| 16 | - // (Origin vs Host) passes. Go routes by path, not Host. | ||
| 17 | - const originHost = request.headers.get("host"); | ||
| 18 | - if (originHost) { | ||
| 19 | - headers.set("host", originHost); | ||
| 20 | - headers.set("x-forwarded-host", originHost); | ||
| 21 | - } | ||
| 22 | - const proto = url.protocol.replace(":", ""); | ||
| 23 | - headers.set("x-forwarded-proto", proto); | ||
| 24 | - | ||
| 25 | const init: RequestInit = { | 15 | const init: RequestInit = { |
| 26 | method: request.method, | 16 | method: request.method, |
| 27 | headers, | 17 | headers, |
| @@ -29,6 +19,13 @@ async function proxyApi({ | |||
| 29 | request.method !== "GET" && request.method !== "HEAD" | 19 | request.method !== "GET" && request.method !== "HEAD" |
| 30 | ? await request.arrayBuffer() | 20 | ? await request.arrayBuffer() |
| 31 | : undefined, | 21 | : undefined, |
| 22 | + // The OAuth callback (/api/auth/callback) is a browser navigation, not an | ||
| 23 | + // XHR: the auth server redirects the user's browser here, and Go responds | ||
| 24 | + // with a 303 to a frontend route (/dashboard, /auth/login). Those routes | ||
| 25 | + // only exist on the SvelteKit origin, not the Go API, so the proxy must | ||
| 26 | + // NOT follow redirects server-side — pass the 303 through for the browser | ||
| 27 | + // to follow via the normal Caddy → SvelteKit chain. | ||
| 28 | + redirect: "manual", | ||
| 32 | // @ts-expect-error Node fetch supports duplex for streaming request bodies. | 29 | // @ts-expect-error Node fetch supports duplex for streaming request bodies. |
| 33 | duplex: "half", | 30 | duplex: "half", |
| 34 | }; | 31 | }; |
modified
web/src/routes/+layout.svelte +3 -4 | @@ -265,8 +265,7 @@ | ||
| 265 | 265 | <div class="space-y-2"> |
| 266 | 266 | <Logo size="sm" /> |
| 267 | 267 | <p class="max-w-xs text-[var(--muted)]"> |
| 268 | - The social RSS reader on AT Protocol. Your feeds, your data, | |
| 269 | - yours. | |
| 268 | + The social RSS reader on AT Protocol. | |
| 270 | 269 | </p> |
| 271 | 270 | </div> |
| 272 | 271 | <div class="flex flex-wrap gap-x-10 gap-y-6"> |
| @@ -317,7 +316,7 @@ | ||
| 317 | 316 | > |
| 318 | 317 | Settings |
| 319 | 318 | </p> |
| 320 | - <ThemeToggle /> | |
| 319 | + <ThemeToggle block={true} /> | |
| 321 | 320 | <button |
| 322 | 321 | class="block font-bold uppercase hover:text-[var(--accent)]" |
| 323 | 322 | onclick={() => (showShortcuts = true)}>Shortcuts</button |
| @@ -338,7 +337,7 @@ | ||
| 338 | 337 | <div |
| 339 | 338 | class="mx-auto flex max-w-5xl flex-wrap items-center justify-between gap-2 px-4 py-3 text-[0.65rem] uppercase tracking-wide text-[var(--muted)]" |
| 340 | 339 | > |
| 341 | - <span>© {new Date().getFullYear()} Glean</span> | |
| 340 | + <span>© {new Date().getFullYear()} Glean.at</span> | |
| 342 | 341 | <span |
| 343 | 342 | >Made in Europe · <a |
| 344 | 343 | href="https://bsky.app/profile/julien.rbrt.fr" |
| @@ -265,8 +265,7 @@ | |||
| 265 | <div class="space-y-2"> | 265 | <div class="space-y-2"> |
| 266 | <Logo size="sm" /> | 266 | <Logo size="sm" /> |
| 267 | <p class="max-w-xs text-[var(--muted)]"> | 267 | <p class="max-w-xs text-[var(--muted)]"> |
| 268 | - The social RSS reader on AT Protocol. Your feeds, your data, | 268 | + The social RSS reader on AT Protocol. |
| 269 | - yours. | ||
| 270 | </p> | 269 | </p> |
| 271 | </div> | 270 | </div> |
| 272 | <div class="flex flex-wrap gap-x-10 gap-y-6"> | 271 | <div class="flex flex-wrap gap-x-10 gap-y-6"> |
| @@ -317,7 +316,7 @@ | |||
| 317 | > | 316 | > |
| 318 | Settings | 317 | Settings |
| 319 | </p> | 318 | </p> |
| 320 | - <ThemeToggle /> | 319 | + <ThemeToggle block={true} /> |
| 321 | <button | 320 | <button |
| 322 | class="block font-bold uppercase hover:text-[var(--accent)]" | 321 | class="block font-bold uppercase hover:text-[var(--accent)]" |
| 323 | onclick={() => (showShortcuts = true)}>Shortcuts</button | 322 | onclick={() => (showShortcuts = true)}>Shortcuts</button |
| @@ -338,7 +337,7 @@ | |||
| 338 | <div | 337 | <div |
| 339 | class="mx-auto flex max-w-5xl flex-wrap items-center justify-between gap-2 px-4 py-3 text-[0.65rem] uppercase tracking-wide text-[var(--muted)]" | 338 | class="mx-auto flex max-w-5xl flex-wrap items-center justify-between gap-2 px-4 py-3 text-[0.65rem] uppercase tracking-wide text-[var(--muted)]" |
| 340 | > | 339 | > |
| 341 | - <span>© {new Date().getFullYear()} Glean</span> | 340 | + <span>© {new Date().getFullYear()} Glean.at</span> |
| 342 | <span | 341 | <span |
| 343 | >Made in Europe · <a | 342 | >Made in Europe · <a |
| 344 | href="https://bsky.app/profile/julien.rbrt.fr" | 343 | href="https://bsky.app/profile/julien.rbrt.fr" |
modified
web/src/routes/dashboard/+page.svelte +1 -0 | @@ -64,6 +64,7 @@ | ||
| 64 | 64 | let digestOpen = $state(false); |
| 65 | 65 | async function markDigestRead() { |
| 66 | 66 | if (!digest) return; |
| 67 | + if (!confirm("Mark these articles as read?")) return; | |
| 67 | 68 | await endpoints.markDigestRead(digest.article_ids); |
| 68 | 69 | digest = { ...digest, consumed: true }; |
| 69 | 70 | } |
| @@ -64,6 +64,7 @@ | |||
| 64 | let digestOpen = $state(false); | 64 | let digestOpen = $state(false); |
| 65 | async function markDigestRead() { | 65 | async function markDigestRead() { |
| 66 | if (!digest) return; | 66 | if (!digest) return; |
| 67 | + if (!confirm("Mark these articles as read?")) return; | ||
| 67 | await endpoints.markDigestRead(digest.article_ids); | 68 | await endpoints.markDigestRead(digest.article_ids); |
| 68 | digest = { ...digest, consumed: true }; | 69 | digest = { ...digest, consumed: true }; |
| 69 | } | 70 | } |
modified
web/svelte.config.js +15 -6 | @@ -1,12 +1,21 @@ | ||
| 1 | -import adapter from '@sveltejs/adapter-node'; | |
| 2 | -import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; | |
| 1 | +import adapter from "@sveltejs/adapter-node"; | |
| 2 | +import { vitePreprocess } from "@sveltejs/vite-plugin-svelte"; | |
| 3 | 3 | |
| 4 | 4 | /** @type {import('@sveltejs/kit').Config} */ |
| 5 | 5 | const config = { |
| 6 | - preprocess: vitePreprocess(), | |
| 7 | - kit: { | |
| 8 | - adapter: adapter() | |
| 9 | - } | |
| 6 | + preprocess: vitePreprocess(), | |
| 7 | + kit: { | |
| 8 | + adapter: adapter(), | |
| 9 | + // CSRF is enforced by the Go API (double-submit glean_csrf cookie + | |
| 10 | + // X-CSRF-Token header). This app has no SvelteKit form actions; the | |
| 11 | + // /api/* POSTs proxied here are form-encoded, which SvelteKit's own | |
| 12 | + // origin check rejects whenever its computed url.origin differs from the | |
| 13 | + // browser's (e.g. behind a TLS-terminating proxy). Trust all origins so | |
| 14 | + // those requests reach the API, where Go's CSRF check applies. | |
| 15 | + csrf: { | |
| 16 | + trustedOrigins: ["*"], | |
| 17 | + }, | |
| 18 | + }, | |
| 10 | 19 | }; |
| 11 | 20 | |
| 12 | 21 | export default config; |
| @@ -1,12 +1,21 @@ | |||
| 1 | -import adapter from '@sveltejs/adapter-node'; | 1 | +import adapter from "@sveltejs/adapter-node"; |
| 2 | -import { vitePreprocess } from '@sveltejs/vite-plugin-svelte'; | 2 | +import { vitePreprocess } from "@sveltejs/vite-plugin-svelte"; |
| 3 | 3 | ||
| 4 | /** @type {import('@sveltejs/kit').Config} */ | 4 | /** @type {import('@sveltejs/kit').Config} */ |
| 5 | const config = { | 5 | const config = { |
| 6 | - preprocess: vitePreprocess(), | 6 | + preprocess: vitePreprocess(), |
| 7 | - kit: { | 7 | + kit: { |
| 8 | - adapter: adapter() | 8 | + adapter: adapter(), |
| 9 | - } | 9 | + // CSRF is enforced by the Go API (double-submit glean_csrf cookie + |
| 10 | + // X-CSRF-Token header). This app has no SvelteKit form actions; the | ||
| 11 | + // /api/* POSTs proxied here are form-encoded, which SvelteKit's own | ||
| 12 | + // origin check rejects whenever its computed url.origin differs from the | ||
| 13 | + // browser's (e.g. behind a TLS-terminating proxy). Trust all origins so | ||
| 14 | + // those requests reach the API, where Go's CSRF check applies. | ||
| 15 | + csrf: { | ||
| 16 | + trustedOrigins: ["*"], | ||
| 17 | + }, | ||
| 18 | + }, | ||
| 10 | }; | 19 | }; |
| 11 | 20 | ||
| 12 | export default config; | 21 | export default config; |