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:
parent
2f63714dba
commit
14fe8e9df9
7 changed files with 324 additions and 1 deletions
|
|
@ -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=
|
||||
|
|
|
|||
90
apps/web/app/(auth)/callback/page.tsx
Normal file
90
apps/web/app/(auth)/callback/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue