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 (
+
+ );
+ }
+
+ 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 && (
+ <>
+
+
+ >
+ )}
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 {