feat(auth): add Google OAuth login

Support Google login that links to existing accounts by email.
When a user who registered via email OTP signs in with Google using
the same email, they are linked to the same account.

Backend:
- Add POST /auth/google endpoint that exchanges Google auth code for
  tokens, fetches user profile, and calls findOrCreateUser()
- Updates user name and avatar from Google profile on first Google login

Frontend:
- Add "Continue with Google" button on login page (shown when
  NEXT_PUBLIC_GOOGLE_CLIENT_ID is configured)
- Add /auth/callback page to handle Google OAuth redirect
- Add loginWithGoogle to auth store and API client
This commit is contained in:
Jiang Bohan 2026-04-07 15:25:26 +08:00
parent 2f63714dba
commit 14fe8e9df9
7 changed files with 324 additions and 1 deletions

View file

@ -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=

View file

@ -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 (
<div className="flex min-h-screen items-center justify-center">
<Card className="w-full max-w-sm">
<CardHeader className="text-center">
<CardTitle className="text-2xl">Login Failed</CardTitle>
<CardDescription>{error}</CardDescription>
</CardHeader>
<CardContent className="flex justify-center">
<a href="/login" className="text-primary underline-offset-4 hover:underline">
Back to login
</a>
</CardContent>
</Card>
</div>
);
}
return (
<div className="flex min-h-screen items-center justify-center">
<Card className="w-full max-w-sm">
<CardHeader className="text-center">
<CardTitle className="text-2xl">Signing in...</CardTitle>
<CardDescription>Please wait while we complete your login</CardDescription>
</CardHeader>
<CardContent className="flex justify-center">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
</CardContent>
</Card>
</div>
);
}
export default function CallbackPage() {
return (
<Suspense fallback={null}>
<CallbackContent />
</Suspense>
);
}

View file

@ -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 (
<div className="flex min-h-screen items-center justify-center">
<Card className="w-full max-w-sm">
@ -307,7 +323,7 @@ function LoginPageContent() {
)}
</form>
</CardContent>
<CardFooter>
<CardFooter className="flex flex-col gap-3">
<Button
type="submit"
form="login-form"
@ -317,6 +333,46 @@ function LoginPageContent() {
>
{submitting ? "Sending code..." : "Continue"}
</Button>
{googleClientId && (
<>
<div className="relative w-full">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-card px-2 text-muted-foreground">or</span>
</div>
</div>
<Button
type="button"
variant="outline"
className="w-full"
size="lg"
onClick={handleGoogleLogin}
disabled={submitting}
>
<svg className="mr-2 h-4 w-4" viewBox="0 0 24 24">
<path
d="M22.56 12.25c0-.78-.07-1.53-.2-2.25H12v4.26h5.92a5.06 5.06 0 0 1-2.2 3.32v2.77h3.57c2.08-1.92 3.28-4.74 3.28-8.1z"
fill="#4285F4"
/>
<path
d="M12 23c2.97 0 5.46-.98 7.28-2.66l-3.57-2.77c-.98.66-2.23 1.06-3.71 1.06-2.86 0-5.29-1.93-6.16-4.53H2.18v2.84C3.99 20.53 7.7 23 12 23z"
fill="#34A853"
/>
<path
d="M5.84 14.09c-.22-.66-.35-1.36-.35-2.09s.13-1.43.35-2.09V7.07H2.18C1.43 8.55 1 10.22 1 12s.43 3.45 1.18 4.93l2.85-2.22.81-.62z"
fill="#FBBC05"
/>
<path
d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z"
fill="#EA4335"
/>
</svg>
Continue with Google
</Button>
</>
)}
</CardFooter>
</Card>
</div>

View file

@ -12,6 +12,7 @@ interface AuthState {
initialize: () => Promise<void>;
sendCode: (email: string) => Promise<void>;
verifyCode: (email: string, code: string) => Promise<User>;
loginWithGoogle: (code: string, redirectUri: string) => Promise<User>;
logout: () => void;
setUser: (user: User) => void;
}
@ -53,6 +54,15 @@ export const useAuthStore = create<AuthState>((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);

View file

@ -144,6 +144,13 @@ export class ApiClient {
});
}
async googleLogin(code: string, redirectUri: string): Promise<LoginResponse> {
return this.fetch("/auth/google", {
method: "POST",
body: JSON.stringify({ code, redirect_uri: redirectUri }),
});
}
async getMe(): Promise<User> {
return this.fetch("/api/me");
}

View file

@ -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) {

View file

@ -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 {