From 5b77d27dcbc0adc4ce47cc4a931bd356caba1d49 Mon Sep 17 00:00:00 2001 From: Sosokker Date: Sun, 9 Mar 2025 23:22:49 +0700 Subject: [PATCH] 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 ( -
- Google Logo - 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({ - - - -
-
{children}
-
-
- -
+ + + + +
+
{children}
+
+
+ +
+
); diff --git a/frontend/package.json b/frontend/package.json index 2bc983b..4da49cc 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -28,6 +28,7 @@ "@radix-ui/react-tooltip": "^1.1.8", "@radix-ui/react-visually-hidden": "^1.1.2", "@react-google-maps/api": "^2.20.6", + "@react-oauth/google": "^0.12.1", "@tailwindcss/typography": "^0.5.16", "@tanstack/react-query": "^5.66.0", "axios": "^1.7.9", diff --git a/frontend/pnpm-lock.yaml b/frontend/pnpm-lock.yaml index 202a360..9abc8cd 100644 --- a/frontend/pnpm-lock.yaml +++ b/frontend/pnpm-lock.yaml @@ -65,6 +65,9 @@ importers: '@react-google-maps/api': specifier: ^2.20.6 version: 2.20.6(react-dom@19.0.0(react@19.0.0))(react@19.0.0) + '@react-oauth/google': + specifier: ^0.12.1 + version: 0.12.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0) '@tailwindcss/typography': specifier: ^0.5.16 version: 0.5.16(tailwindcss@3.4.17) @@ -931,6 +934,12 @@ packages: '@react-google-maps/marker-clusterer@2.20.0': resolution: {integrity: sha512-tieX9Va5w1yP88vMgfH1pHTacDQ9TgDTjox3tLlisKDXRQWdjw+QeVVghhf5XqqIxXHgPdcGwBvKY6UP+SIvLw==} + '@react-oauth/google@0.12.1': + resolution: {integrity: sha512-qagsy22t+7UdkYAiT5ZhfM4StXi9PPNvw0zuwNmabrWyMKddczMtBIOARflbaIj+wHiQjnMAsZmzsUYuXeyoSg==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + '@rtsao/scc@1.1.0': resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} @@ -3423,6 +3432,11 @@ snapshots: '@react-google-maps/marker-clusterer@2.20.0': {} + '@react-oauth/google@0.12.1(react-dom@19.0.0(react@19.0.0))(react@19.0.0)': + dependencies: + react: 19.0.0 + react-dom: 19.0.0(react@19.0.0) + '@rtsao/scc@1.1.0': {} '@rushstack/eslint-patch@1.10.5': {}