Date: Sun, 9 Mar 2025 23:22:49 +0700
Subject: [PATCH 1/5] feat: add google oauth
---
backend/internal/api/api.go | 1 +
backend/internal/api/oauth.go | 58 +++++++++++++++++++++++
backend/internal/utilities/jwt.go | 42 +++++++++++-----
backend/internal/utilities/oauth.go | 41 ++++++++++++++++
frontend/app/auth/signin/google-oauth.tsx | 52 +++++++++++++++++---
frontend/app/auth/signin/page.tsx | 1 +
frontend/app/layout.tsx | 21 ++++----
frontend/package.json | 1 +
frontend/pnpm-lock.yaml | 14 ++++++
9 files changed, 202 insertions(+), 29 deletions(-)
create mode 100644 backend/internal/api/oauth.go
create mode 100644 backend/internal/utilities/oauth.go
diff --git a/backend/internal/api/api.go b/backend/internal/api/api.go
index cc64d33..3f9b560 100644
--- a/backend/internal/api/api.go
+++ b/backend/internal/api/api.go
@@ -76,6 +76,7 @@ func (a *api) Routes() *chi.Mux {
a.registerAuthRoutes(r, api)
a.registerCropRoutes(r, api)
a.registerPlantRoutes(r, api)
+ a.registerOauthRoutes(r, api)
})
router.Group(func(r chi.Router) {
diff --git a/backend/internal/api/oauth.go b/backend/internal/api/oauth.go
new file mode 100644
index 0000000..4b61ca7
--- /dev/null
+++ b/backend/internal/api/oauth.go
@@ -0,0 +1,58 @@
+package api
+
+import (
+ "context"
+ "errors"
+ "net/http"
+
+ "github.com/danielgtaylor/huma/v2"
+ "github.com/forfarm/backend/internal/utilities"
+ "github.com/go-chi/chi/v5"
+)
+
+func (a *api) registerOauthRoutes(_ chi.Router, apiInstance huma.API) {
+ tags := []string{"oauth"}
+
+ huma.Register(apiInstance, huma.Operation{
+ OperationID: "oauth_exchange",
+ Method: http.MethodPost,
+ Path: "/oauth/exchange",
+ Tags: tags,
+ }, a.exchangeHandler)
+}
+
+type ExchangeTokenInput struct {
+ Body struct {
+ AccessToken string `json:"access_token" example:"Google ID token obtained after login"`
+ }
+}
+
+type ExchangeTokenOutput struct {
+ Body struct {
+ JWT string `json:"jwt" example:"Fresh JWT for frontend authentication"`
+ Email string `json:"email" example:"Email address of the user"`
+ }
+}
+
+// exchangeHandler now assumes the provided access token is a Google ID token.
+// It verifies the token with Google and then generates your own JWT.
+func (a *api) exchangeHandler(ctx context.Context, input *ExchangeTokenInput) (*ExchangeTokenOutput, error) {
+ if input.Body.AccessToken == "" {
+ return nil, errors.New("access token is required")
+ }
+
+ googleUserID, email, err := utilities.ExtractGoogleUserID(input.Body.AccessToken)
+ if err != nil {
+ return nil, errors.New("invalid Google ID token")
+ }
+
+ newJWT, err := utilities.CreateJwtToken(googleUserID)
+ if err != nil {
+ return nil, err
+ }
+
+ resp := &ExchangeTokenOutput{}
+ resp.Body.JWT = newJWT
+ resp.Body.Email = email
+ return resp, nil
+}
diff --git a/backend/internal/utilities/jwt.go b/backend/internal/utilities/jwt.go
index f5cdc8f..caeba1d 100644
--- a/backend/internal/utilities/jwt.go
+++ b/backend/internal/utilities/jwt.go
@@ -8,7 +8,8 @@ import (
"github.com/golang-jwt/jwt/v5"
)
-var deafultSecretKey = []byte(config.JWT_SECRET_KEY)
+// TODO: Change later
+var defaultSecretKey = []byte(config.JWT_SECRET_KEY)
func CreateJwtToken(uuid string) (string, error) {
token := jwt.NewWithClaims(jwt.SigningMethodHS256, jwt.MapClaims{
@@ -16,7 +17,7 @@ func CreateJwtToken(uuid string) (string, error) {
"exp": time.Now().Add(time.Hour * 24).Unix(),
})
- tokenString, err := token.SignedString(deafultSecretKey)
+ tokenString, err := token.SignedString(defaultSecretKey)
if err != nil {
return "", err
}
@@ -25,7 +26,7 @@ func CreateJwtToken(uuid string) (string, error) {
}
func VerifyJwtToken(tokenString string, customKey ...[]byte) error {
- secretKey := deafultSecretKey
+ secretKey := defaultSecretKey
if len(customKey) > 0 {
if len(customKey[0]) < 32 {
return errors.New("provided key is too short, minimum length is 32 bytes")
@@ -52,25 +53,40 @@ func VerifyJwtToken(tokenString string, customKey ...[]byte) error {
return nil
}
-// ExtractUUIDFromToken decodes the JWT token using the default secret key,
-// and returns the uuid claim contained within the token.
-func ExtractUUIDFromToken(tokenString string) (string, error) {
+func ExtractUUIDFromToken(tokenString string, customKey ...[]byte) (string, error) {
+ secretKey := defaultSecretKey
+ if len(customKey) > 0 {
+ if len(customKey[0]) < 32 {
+ return "", errors.New("provided key is too short, minimum length is 32 bytes")
+ }
+ secretKey = customKey[0]
+ }
+
token, err := jwt.Parse(tokenString, func(token *jwt.Token) (interface{}, error) {
if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
return nil, jwt.ErrSignatureInvalid
}
- return deafultSecretKey, nil
+
+ return secretKey, nil
})
+
if err != nil {
return "", err
}
- if claims, ok := token.Claims.(jwt.MapClaims); ok && token.Valid {
- if uuid, ok := claims["uuid"].(string); ok {
- return uuid, nil
- }
- return "", errors.New("uuid not found in token")
+ if !token.Valid {
+ return "", jwt.ErrSignatureInvalid
}
- return "", errors.New("invalid token claims")
+ claims, ok := token.Claims.(jwt.MapClaims)
+ if !ok {
+ return "", errors.New("unable to parse claims")
+ }
+
+ userID, ok := claims["uuid"].(string)
+ if !ok || userID == "" {
+ return "", errors.New("uuid claim is missing or invalid")
+ }
+
+ return userID, nil
}
diff --git a/backend/internal/utilities/oauth.go b/backend/internal/utilities/oauth.go
new file mode 100644
index 0000000..6fa7b5e
--- /dev/null
+++ b/backend/internal/utilities/oauth.go
@@ -0,0 +1,41 @@
+package utilities
+
+import (
+ "encoding/json"
+ "errors"
+ "fmt"
+ "net/http"
+)
+
+type GoogleTokenInfo struct {
+ Sub string `json:"sub"` // Unique Google user identifier.
+ Email string `json:"email"` // The user's email address.
+}
+
+func ExtractGoogleUserID(idToken string) (string, string, error) {
+ if idToken == "" {
+ return "", "", errors.New("provided id token is empty")
+ }
+
+ url := fmt.Sprintf("https://oauth2.googleapis.com/tokeninfo?id_token=%s", idToken)
+ resp, err := http.Get(url)
+ if err != nil {
+ return "", "", fmt.Errorf("error verifying Google ID token: %w", err)
+ }
+ defer resp.Body.Close()
+
+ if resp.StatusCode != http.StatusOK {
+ return "", "", fmt.Errorf("tokeninfo endpoint returned unexpected status: %d", resp.StatusCode)
+ }
+
+ var tokenInfo GoogleTokenInfo
+ if err := json.NewDecoder(resp.Body).Decode(&tokenInfo); err != nil {
+ return "", "", fmt.Errorf("error decoding token info response: %w", err)
+ }
+
+ if tokenInfo.Sub == "" {
+ return "", "", errors.New("Google token missing 'sub' claim")
+ }
+
+ return tokenInfo.Sub, tokenInfo.Email, nil
+}
diff --git a/frontend/app/auth/signin/google-oauth.tsx b/frontend/app/auth/signin/google-oauth.tsx
index 8d5f1da..92d10e4 100644
--- a/frontend/app/auth/signin/google-oauth.tsx
+++ b/frontend/app/auth/signin/google-oauth.tsx
@@ -1,10 +1,48 @@
-import Image from "next/image";
+import React, { useContext } from "react";
+import { useRouter } from "next/navigation";
+import { GoogleLogin, CredentialResponse } from "@react-oauth/google";
+import { SessionContext } from "@/context/SessionContext";
+
+interface OAuthExchangeData {
+ jwt: string;
+ email: string;
+}
export function GoogleSigninButton() {
- return (
-
-
- Sign in with Google
-
- );
+ const router = useRouter();
+ const session = useContext(SessionContext);
+
+ const handleLoginSuccess = async (credentialResponse: CredentialResponse) => {
+ if (!credentialResponse.credential) {
+ console.error("No credential returned from Google");
+ return;
+ }
+
+ try {
+ const exchangeRes = await fetch(`${process.env.NEXT_PUBLIC_API_URL || "http://localhost:8000"}/oauth/exchange`, {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({ access_token: credentialResponse.credential }),
+ });
+ if (!exchangeRes.ok) {
+ throw new Error("Exchange token request failed");
+ }
+ const exchangeData: OAuthExchangeData = await exchangeRes.json();
+ const jwt = exchangeData.jwt;
+ const email = exchangeData.email;
+
+ session!.setToken(jwt);
+ session!.setUser(email);
+
+ router.push("/farms");
+ } catch (error) {
+ console.error("Error during token exchange:", error);
+ }
+ };
+
+ const handleLoginError = () => {
+ console.error("Google login failed");
+ };
+
+ return ;
}
diff --git a/frontend/app/auth/signin/page.tsx b/frontend/app/auth/signin/page.tsx
index 450ae1e..f3b460e 100644
--- a/frontend/app/auth/signin/page.tsx
+++ b/frontend/app/auth/signin/page.tsx
@@ -54,6 +54,7 @@ export default function SigninPage() {
session!.setUser(values.email);
router.push("/setup");
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
} catch (error: any) {
console.error("Error logging in:", error);
setServerError(error.message || "Invalid email or password. Please try again.");
diff --git a/frontend/app/layout.tsx b/frontend/app/layout.tsx
index a1cc0ed..0c71842 100644
--- a/frontend/app/layout.tsx
+++ b/frontend/app/layout.tsx
@@ -5,6 +5,7 @@ import { ThemeProvider } from "@/components/theme-provider";
import { SessionProvider } from "@/context/SessionContext";
import ReactQueryProvider from "@/lib/ReactQueryProvider";
+import { GoogleOAuthProvider } from "@react-oauth/google";
const poppins = Poppins({
subsets: ["latin"],
@@ -32,15 +33,17 @@ export default function RootLayout({