diff --git a/.env.example b/.env.example index bfa38ae7..8a98d2d8 100644 --- a/.env.example +++ b/.env.example @@ -29,6 +29,7 @@ RESEND_FROM_EMAIL=noreply@multica.ai GOOGLE_CLIENT_ID= GOOGLE_CLIENT_SECRET= GOOGLE_REDIRECT_URI=http://localhost:3000/auth/callback +NEXT_PUBLIC_GOOGLE_CLIENT_ID= # S3 / CloudFront S3_BUCKET= diff --git a/apps/web/app/(auth)/callback/page.tsx b/apps/web/app/(auth)/callback/page.tsx new file mode 100644 index 00000000..660f9fd6 --- /dev/null +++ b/apps/web/app/(auth)/callback/page.tsx @@ -0,0 +1,90 @@ +"use client"; + +import { Suspense, useEffect, useState } from "react"; +import { useSearchParams, useRouter } from "next/navigation"; +import { useAuthStore } from "@/features/auth"; +import { useWorkspaceStore } from "@/features/workspace"; +import { api } from "@/shared/api"; +import { + Card, + CardHeader, + CardTitle, + CardDescription, + CardContent, +} from "@/components/ui/card"; +import { Loader2 } from "lucide-react"; + +function CallbackContent() { + const router = useRouter(); + const searchParams = useSearchParams(); + const loginWithGoogle = useAuthStore((s) => s.loginWithGoogle); + const hydrateWorkspace = useWorkspaceStore((s) => s.hydrateWorkspace); + const [error, setError] = useState(""); + + useEffect(() => { + const code = searchParams.get("code"); + if (!code) { + setError("Missing authorization code"); + return; + } + + const errorParam = searchParams.get("error"); + if (errorParam) { + setError(errorParam === "access_denied" ? "Access denied" : errorParam); + return; + } + + const redirectUri = `${window.location.origin}/auth/callback`; + + loginWithGoogle(code, redirectUri) + .then(async () => { + const wsList = await api.listWorkspaces(); + const lastWsId = localStorage.getItem("multica_workspace_id"); + await hydrateWorkspace(wsList, lastWsId); + router.push("/issues"); + }) + .catch((err) => { + setError(err instanceof Error ? err.message : "Login failed"); + }); + }, [searchParams, loginWithGoogle, hydrateWorkspace, router]); + + if (error) { + return ( +
+ + + Login Failed + {error} + + + + Back to login + + + +
+ ); + } + + return ( +
+ + + Signing in... + Please wait while we complete your login + + + + + +
+ ); +} + +export default function CallbackPage() { + return ( + + + + ); +} diff --git a/apps/web/app/(auth)/login/page.tsx b/apps/web/app/(auth)/login/page.tsx index 74d60858..34194933 100644 --- a/apps/web/app/(auth)/login/page.tsx +++ b/apps/web/app/(auth)/login/page.tsx @@ -282,6 +282,22 @@ function LoginPageContent() { ); } + const googleClientId = process.env.NEXT_PUBLIC_GOOGLE_CLIENT_ID; + + const handleGoogleLogin = () => { + if (!googleClientId) return; + const redirectUri = `${window.location.origin}/auth/callback`; + const params = new URLSearchParams({ + client_id: googleClientId, + redirect_uri: redirectUri, + response_type: "code", + scope: "openid email profile", + access_type: "offline", + prompt: "select_account", + }); + window.location.href = `https://accounts.google.com/o/oauth2/v2/auth?${params}`; + }; + return (
@@ -307,7 +323,7 @@ function LoginPageContent() { )} - + + {googleClientId && ( + <> +
+
+ +
+
+ or +
+
+ + + )}
diff --git a/apps/web/features/auth/store.ts b/apps/web/features/auth/store.ts index 0f6ce7be..12f85513 100644 --- a/apps/web/features/auth/store.ts +++ b/apps/web/features/auth/store.ts @@ -12,6 +12,7 @@ interface AuthState { initialize: () => Promise; sendCode: (email: string) => Promise; verifyCode: (email: string, code: string) => Promise; + loginWithGoogle: (code: string, redirectUri: string) => Promise; logout: () => void; setUser: (user: User) => void; } @@ -53,6 +54,15 @@ export const useAuthStore = create((set) => ({ return user; }, + loginWithGoogle: async (code: string, redirectUri: string) => { + const { token, user } = await api.googleLogin(code, redirectUri); + localStorage.setItem("multica_token", token); + api.setToken(token); + setLoggedInCookie(); + set({ user }); + return user; + }, + logout: () => { localStorage.removeItem("multica_token"); api.setToken(null); diff --git a/apps/web/shared/api/client.ts b/apps/web/shared/api/client.ts index f7323a16..5d7d2e79 100644 --- a/apps/web/shared/api/client.ts +++ b/apps/web/shared/api/client.ts @@ -144,6 +144,13 @@ export class ApiClient { }); } + async googleLogin(code: string, redirectUri: string): Promise { + return this.fetch("/auth/google", { + method: "POST", + body: JSON.stringify({ code, redirect_uri: redirectUri }), + }); + } + async getMe(): Promise { return this.fetch("/api/me"); } diff --git a/server/cmd/server/router.go b/server/cmd/server/router.go index 477b9ae0..a7500007 100644 --- a/server/cmd/server/router.go +++ b/server/cmd/server/router.go @@ -82,6 +82,7 @@ func NewRouter(pool *pgxpool.Pool, hub *realtime.Hub, bus *events.Bus) chi.Route // Auth (public) r.Post("/auth/send-code", h.SendCode) r.Post("/auth/verify-code", h.VerifyCode) + r.Post("/auth/google", h.GoogleLogin) // Daemon API routes (all require a valid token) r.Route("/api/daemon", func(r chi.Router) { diff --git a/server/internal/handler/auth.go b/server/internal/handler/auth.go index 61807b81..7fc99b96 100644 --- a/server/internal/handler/auth.go +++ b/server/internal/handler/auth.go @@ -7,8 +7,10 @@ import ( "encoding/binary" "encoding/json" "fmt" + "io" "log/slog" "net/http" + "net/url" "os" "strings" "time" @@ -334,6 +336,162 @@ type UpdateMeRequest struct { AvatarURL *string `json:"avatar_url"` } +type GoogleLoginRequest struct { + Code string `json:"code"` + RedirectURI string `json:"redirect_uri"` +} + +type googleTokenResponse struct { + AccessToken string `json:"access_token"` + IDToken string `json:"id_token"` + TokenType string `json:"token_type"` +} + +type googleUserInfo struct { + Email string `json:"email"` + Name string `json:"name"` + Picture string `json:"picture"` +} + +func (h *Handler) GoogleLogin(w http.ResponseWriter, r *http.Request) { + var req GoogleLoginRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeError(w, http.StatusBadRequest, "invalid request body") + return + } + + if req.Code == "" { + writeError(w, http.StatusBadRequest, "code is required") + return + } + + clientID := os.Getenv("GOOGLE_CLIENT_ID") + clientSecret := os.Getenv("GOOGLE_CLIENT_SECRET") + if clientID == "" || clientSecret == "" { + writeError(w, http.StatusServiceUnavailable, "Google login is not configured") + return + } + + redirectURI := req.RedirectURI + if redirectURI == "" { + redirectURI = os.Getenv("GOOGLE_REDIRECT_URI") + } + + // Exchange authorization code for tokens. + tokenResp, err := http.PostForm("https://oauth2.googleapis.com/token", url.Values{ + "code": {req.Code}, + "client_id": {clientID}, + "client_secret": {clientSecret}, + "redirect_uri": {redirectURI}, + "grant_type": {"authorization_code"}, + }) + if err != nil { + slog.Error("google oauth token exchange failed", "error", err) + writeError(w, http.StatusBadGateway, "failed to exchange code with Google") + return + } + defer tokenResp.Body.Close() + + tokenBody, err := io.ReadAll(tokenResp.Body) + if err != nil { + writeError(w, http.StatusBadGateway, "failed to read Google token response") + return + } + + if tokenResp.StatusCode != http.StatusOK { + slog.Error("google oauth token exchange returned error", "status", tokenResp.StatusCode, "body", string(tokenBody)) + writeError(w, http.StatusBadRequest, "failed to exchange code with Google") + return + } + + var gToken googleTokenResponse + if err := json.Unmarshal(tokenBody, &gToken); err != nil { + writeError(w, http.StatusBadGateway, "failed to parse Google token response") + return + } + + // Fetch user info from Google. + userInfoReq, _ := http.NewRequestWithContext(r.Context(), http.MethodGet, "https://www.googleapis.com/oauth2/v2/userinfo", nil) + userInfoReq.Header.Set("Authorization", "Bearer "+gToken.AccessToken) + + userInfoResp, err := http.DefaultClient.Do(userInfoReq) + if err != nil { + slog.Error("google userinfo fetch failed", "error", err) + writeError(w, http.StatusBadGateway, "failed to fetch user info from Google") + return + } + defer userInfoResp.Body.Close() + + var gUser googleUserInfo + if err := json.NewDecoder(userInfoResp.Body).Decode(&gUser); err != nil { + writeError(w, http.StatusBadGateway, "failed to parse Google user info") + return + } + + if gUser.Email == "" { + writeError(w, http.StatusBadRequest, "Google account has no email") + return + } + + email := strings.ToLower(strings.TrimSpace(gUser.Email)) + + user, err := h.findOrCreateUser(r.Context(), email) + if err != nil { + writeError(w, http.StatusInternalServerError, "failed to create user") + return + } + + // Update name and avatar from Google profile if the user was just created + // (default name is email prefix) or has no avatar yet. + needsUpdate := false + newName := user.Name + newAvatar := user.AvatarUrl + + if gUser.Name != "" && user.Name == strings.Split(email, "@")[0] { + newName = gUser.Name + needsUpdate = true + } + if gUser.Picture != "" && !user.AvatarUrl.Valid { + newAvatar = pgtype.Text{String: gUser.Picture, Valid: true} + needsUpdate = true + } + + if needsUpdate { + updated, err := h.Queries.UpdateUser(r.Context(), db.UpdateUserParams{ + ID: user.ID, + Name: newName, + AvatarUrl: newAvatar, + }) + if err == nil { + user = updated + } + } + + if err := h.ensureUserWorkspace(r.Context(), user); err != nil { + writeError(w, http.StatusInternalServerError, "failed to provision workspace") + return + } + + tokenString, err := h.issueJWT(user) + if err != nil { + slog.Warn("google login failed", append(logger.RequestAttrs(r), "error", err, "email", email)...) + writeError(w, http.StatusInternalServerError, "failed to generate token") + return + } + + if h.CFSigner != nil { + for _, cookie := range h.CFSigner.SignedCookies(time.Now().Add(72 * time.Hour)) { + http.SetCookie(w, cookie) + } + } + + slog.Info("user logged in via google", append(logger.RequestAttrs(r), "user_id", uuidToString(user.ID), "email", user.Email)...) + writeJSON(w, http.StatusOK, LoginResponse{ + Token: tokenString, + User: userToResponse(user), + }) +} + func (h *Handler) UpdateMe(w http.ResponseWriter, r *http.Request) { userID, ok := requireUserID(w, r) if !ok {