diff --git a/.gitignore b/.gitignore index e491e85..96b55a4 100644 --- a/.gitignore +++ b/.gitignore @@ -35,6 +35,7 @@ go.work.sum .claude/ e2b/ .impeccable.md +.gstack ## Builds builds/ diff --git a/envd/go.mod b/envd/go.mod index be2c95a..c739bc6 100644 --- a/envd/go.mod +++ b/envd/go.mod @@ -1,6 +1,6 @@ module git.omukk.dev/wrenn/sandbox/envd -go 1.25.5 +go 1.25.8 require ( connectrpc.com/authn v0.1.0 diff --git a/frontend/src/routes/auth/github/callback/+page.svelte b/frontend/src/routes/auth/github/callback/+page.svelte index 8ca4472..6ede837 100644 --- a/frontend/src/routes/auth/github/callback/+page.svelte +++ b/frontend/src/routes/auth/github/callback/+page.svelte @@ -4,17 +4,37 @@ import { auth } from '$lib/auth.svelte'; import { teams } from '$lib/teams.svelte'; + // Check for error in URL params (errors are still passed via query params). const params = $page.url.searchParams; const error = params.get('error'); + function getCookie(name: string): string | null { + const match = document.cookie.match(new RegExp(`(?:^|; )${name}=([^;]*)`)); + return match ? decodeURIComponent(match[1]) : null; + } + + function clearOAuthCookies() { + for (const name of [ + 'wrenn_oauth_token', + 'wrenn_oauth_user_id', + 'wrenn_oauth_team_id', + 'wrenn_oauth_email', + 'wrenn_oauth_name' + ]) { + document.cookie = `${name}=; path=/auth/; max-age=0`; + } + } + if (error) { goto(`/login?error=${encodeURIComponent(error)}`); } else { - const token = params.get('token'); - const userId = params.get('user_id'); - const teamId = params.get('team_id'); - const email = params.get('email'); - const name = params.get('name') ?? ''; + const token = getCookie('wrenn_oauth_token'); + const userId = getCookie('wrenn_oauth_user_id'); + const teamId = getCookie('wrenn_oauth_team_id'); + const email = getCookie('wrenn_oauth_email'); + const name = getCookie('wrenn_oauth_name') ?? ''; + + clearOAuthCookies(); if (token && userId && teamId && email) { teams.reset(); diff --git a/go.mod b/go.mod index aaa473d..8698f0c 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module git.omukk.dev/wrenn/sandbox -go 1.25.0 +go 1.25.8 require ( connectrpc.com/connect v1.19.1 diff --git a/internal/api/handlers_auth.go b/internal/api/handlers_auth.go index b1d4915..f3e9c34 100644 --- a/internal/api/handlers_auth.go +++ b/internal/api/handlers_auth.go @@ -3,6 +3,7 @@ package api import ( "context" "errors" + "log/slog" "net/http" "strings" @@ -202,6 +203,7 @@ func (h *authHandler) Login(w http.ResponseWriter, r *http.Request) { user, err := h.db.GetUserByEmail(ctx, req.Email) if err != nil { if errors.Is(err, pgx.ErrNoRows) { + slog.Warn("login failed: unknown email", "email", req.Email, "ip", r.RemoteAddr) writeError(w, http.StatusUnauthorized, "unauthorized", "invalid email or password") return } @@ -210,10 +212,12 @@ func (h *authHandler) Login(w http.ResponseWriter, r *http.Request) { } if !user.PasswordHash.Valid { + slog.Warn("login failed: no password set", "email", req.Email, "ip", r.RemoteAddr) writeError(w, http.StatusUnauthorized, "unauthorized", "invalid email or password") return } if err := auth.CheckPassword(user.PasswordHash.String, req.Password); err != nil { + slog.Warn("login failed: wrong password", "email", req.Email, "ip", r.RemoteAddr) writeError(w, http.StatusUnauthorized, "unauthorized", "invalid email or password") return } diff --git a/internal/api/handlers_oauth.go b/internal/api/handlers_oauth.go index a9c448a..961f2e3 100644 --- a/internal/api/handlers_oauth.go +++ b/internal/api/handlers_oauth.go @@ -300,14 +300,23 @@ func (h *oauthHandler) retryAsLogin(w http.ResponseWriter, r *http.Request, prov } func redirectWithToken(w http.ResponseWriter, r *http.Request, base, token, userID, teamID, email, name string) { - u := base + "?" + url.Values{ - "token": {token}, - "user_id": {userID}, - "team_id": {teamID}, - "email": {email}, - "name": {name}, - }.Encode() - http.Redirect(w, r, u, http.StatusFound) + // Set auth data as short-lived cookies instead of URL query parameters. + // This prevents token leakage via server access logs, Referer headers, and browser history. + for _, c := range []http.Cookie{ + {Name: "wrenn_oauth_token", Value: token}, + {Name: "wrenn_oauth_user_id", Value: userID}, + {Name: "wrenn_oauth_team_id", Value: teamID}, + {Name: "wrenn_oauth_email", Value: email}, + {Name: "wrenn_oauth_name", Value: name}, + } { + c.Path = "/auth/" + c.MaxAge = 60 + c.HttpOnly = false // frontend JS must read these + c.SameSite = http.SameSiteLaxMode + c.Secure = isSecure(r) + http.SetCookie(w, &c) + } + http.Redirect(w, r, base, http.StatusFound) } func redirectWithError(w http.ResponseWriter, r *http.Request, base, code string) { diff --git a/internal/api/middleware_auth.go b/internal/api/middleware_auth.go index 985b289..1d5fd98 100644 --- a/internal/api/middleware_auth.go +++ b/internal/api/middleware_auth.go @@ -20,6 +20,7 @@ func requireAPIKeyOrJWT(queries *db.Queries, jwtSecret []byte) func(http.Handler hash := auth.HashAPIKey(key) row, err := queries.GetAPIKeyByHash(r.Context(), hash) if err != nil { + slog.Warn("api key auth failed", "prefix", auth.APIKeyPrefix(key), "ip", r.RemoteAddr) writeError(w, http.StatusUnauthorized, "unauthorized", "invalid API key") return } @@ -42,6 +43,7 @@ func requireAPIKeyOrJWT(queries *db.Queries, jwtSecret []byte) func(http.Handler tokenStr := strings.TrimPrefix(header, "Bearer ") claims, err := auth.VerifyJWT(jwtSecret, tokenStr) if err != nil { + slog.Warn("jwt auth failed", "error", err, "ip", r.RemoteAddr) writeError(w, http.StatusUnauthorized, "unauthorized", "invalid or expired token") return } diff --git a/internal/api/server.go b/internal/api/server.go index 5d854b9..d3b5c37 100644 --- a/internal/api/server.go +++ b/internal/api/server.go @@ -208,7 +208,7 @@ func serveDocs(w http.ResponseWriter, r *http.Request) { Wrenn Sandbox API - +