From 1e25a5b4962b595e2dcbbc54e09de7374d19be2a Mon Sep 17 00:00:00 2001 From: MythEclipse Date: Mon, 11 Aug 2025 23:47:15 +0700 Subject: [PATCH] feat(auth): Update Google OAuth integration to enhance response structure and improve callback handling --- docs/google_oauth_integration.md | 316 +++++++++++++++++- .../v1/auth/google/google_oauth_controller.rs | 13 +- .../v1/auth/google/google_oauth_service.rs | 21 +- imphnen-iam/src/v1/users/users_service.rs | 31 +- .../iam/auth/google/google_oauth_flow_test.rs | 2 +- 5 files changed, 338 insertions(+), 45 deletions(-) diff --git a/docs/google_oauth_integration.md b/docs/google_oauth_integration.md index 265a4fd..82ccd99 100644 --- a/docs/google_oauth_integration.md +++ b/docs/google_oauth_integration.md @@ -10,7 +10,7 @@ To enable Google OAuth 2.1 authentication, the following environment variables m * `GOOGLE_CLIENT_ID`: Your Google OAuth 2.1 Client ID. * `GOOGLE_CLIENT_SECRET`: Your Google OAuth 2.1 Client Secret. -* `GOOGLE_REDIRECT_URL`: The URL to which Google will redirect the user after successful authentication. This must match one of the authorized redirect URIs configured in your Google Cloud Console. +* `GOOGLE_REDIRECT_URL`: The URL to which Google will redirect the user after successful authentication. This must match one of the authorized redirect URIs configured in your Google Cloud Console (e.g., `http://127.0.0.1:8080/api/v1/auth/google/callback`). ### Obtaining Credentials from Google Cloud Console @@ -46,11 +46,13 @@ To enable Google OAuth 2.1 authentication, the following environment variables m * **Endpoint:** `/api/v1/auth/google/callback` * **Method:** `GET` -* **Description:** This endpoint handles the redirect from Google after the user has authenticated and granted permissions. Google sends an authorization `code` and a `state` parameter to this URL. The application then uses this `code` to exchange it for an access token and user information with Google. Upon successful validation and user creation/login, a full `LoginResponse` object is returned, identical to the credential-based login, which includes an access token, refresh token, and user details. +* **Description:** This endpoint handles the redirect from Google after the user has authenticated and granted permissions. Google sends an authorization `code` and a `state` parameter to this URL. The application then uses this `code` to exchange it for an access token and user information with Google. Upon successful validation and user creation/login, a JSON response is returned containing authentication tokens and user details, identical to the credential-based login endpoint. * **Query Parameters:** - * `code` (required): The authorization code provided by Google. - * `state` (required): The CSRF token generated during the login initiation. + * `code` (required): The authorization code provided by Google. + * `state` (required): The CSRF token generated during the login initiation. + +* **Response:** Returns a JSON object containing authentication tokens and user information. * **Example `curl` command (conceptual, as `code` and `state` are dynamic):** @@ -62,7 +64,7 @@ To enable Google OAuth 2.1 authentication, the following environment variables m curl -v "http://127.0.0.1:8080/api/v1/auth/google/callback?code=&state=" ``` - A successful response will typically return a JSON object containing the generated JWT: +* **Success Response (200 OK):** ```json { @@ -92,20 +94,298 @@ To enable Google OAuth 2.1 authentication, the following environment variables m } ``` +## React Frontend Implementation + +This section outlines how to integrate the Google OAuth 2.1 flow into a React application using the JSON API response approach. + +### 1. Initiating the Login Flow + +Users will click a button or link to initiate the Google OAuth process. You can implement this using either a popup window or a full page redirect approach. + +#### Approach 1: Popup Window (Recommended) + +```jsx +// Example React Component for Google Login with Popup +import React, { useState } from 'react'; + +function GoogleLoginButton() { + const [isLoading, setIsLoading] = useState(false); + + const handleLogin = () => { + setIsLoading(true); + + // Open Google OAuth in popup window + const popup = window.open( + 'http://127.0.0.1:8080/api/v1/auth/google/login', + 'googleOAuth', + 'width=500,height=600,scrollbars=yes,resizable=yes' + ); + + // Check if popup is closed manually + const checkClosed = setInterval(() => { + if (popup.closed) { + clearInterval(checkClosed); + setIsLoading(false); + console.log('OAuth popup was closed without completion'); + } + }, 1000); + + // Listen for the popup to navigate to the callback URL + const checkCallback = setInterval(async () => { + try { + if (popup.location.href.includes('/api/v1/auth/google/callback')) { + clearInterval(checkCallback); + clearInterval(checkClosed); + + // Wait a moment for the request to complete, then get the response + setTimeout(async () => { + try { + // The popup now contains the JSON response from the callback + const response = await fetch(popup.location.href); + const data = await response.json(); + + if (response.ok) { + // Store tokens securely + localStorage.setItem('accessToken', data.token.access_token); + localStorage.setItem('refreshToken', data.token.refresh_token); + localStorage.setItem('user', JSON.stringify(data.user)); + + popup.close(); + setIsLoading(false); + + // Redirect to dashboard or update app state + window.location.href = '/dashboard'; + } else { + throw new Error('Authentication failed'); + } + } catch (error) { + console.error('OAuth callback error:', error); + popup.close(); + setIsLoading(false); + alert('Google login failed. Please try again.'); + } + }, 1000); + } + } catch (error) { + // Cross-origin error is expected until callback URL is reached + } + }, 1000); + }; + + return ( + + ); +} + +export default GoogleLoginButton; +``` + +#### Approach 2: Full Page Redirect + +```jsx +// Example React Component for Google Login with Full Redirect +import React from 'react'; + +function GoogleLoginButton() { + const handleLogin = () => { + // Store current location to redirect back after auth + localStorage.setItem('preAuthLocation', window.location.pathname); + + // Redirect to backend's Google OAuth login endpoint + window.location.href = 'http://127.0.0.1:8080/api/v1/auth/google/login'; + }; + + return ( + + ); +} + +export default GoogleLoginButton; +``` + +### 2. Handling the OAuth Callback (For Full Redirect Approach) + +If using the full page redirect approach, you'll need a callback component to handle the OAuth response. + +```jsx +// Example React Component for Google OAuth Callback Handler +import React, { useEffect, useState } from 'react'; +import { useLocation, useNavigate } from 'react-router-dom'; + +function GoogleAuthCallback() { + const location = useLocation(); + const navigate = useNavigate(); + const [isProcessing, setIsProcessing] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const handleCallback = async () => { + try { + // Extract query parameters from current URL + const params = new URLSearchParams(location.search); + const code = params.get('code'); + const state = params.get('state'); + + if (!code || !state) { + throw new Error('Missing required OAuth parameters'); + } + + // Make request to your backend callback endpoint + const response = await fetch( + `http://127.0.0.1:8080/api/v1/auth/google/callback${location.search}`, + { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + } + ); + + if (!response.ok) { + throw new Error(`HTTP error! status: ${response.status}`); + } + + const data = await response.json(); + + // Store authentication data + localStorage.setItem('accessToken', data.token.access_token); + localStorage.setItem('refreshToken', data.token.refresh_token); + localStorage.setItem('user', JSON.stringify(data.user)); + + // Redirect to intended location or dashboard + const preAuthLocation = localStorage.getItem('preAuthLocation') || '/dashboard'; + localStorage.removeItem('preAuthLocation'); + + navigate(preAuthLocation, { replace: true }); + + } catch (error) { + console.error('Google OAuth callback error:', error); + setError(error.message); + setIsProcessing(false); + } + }; + + handleCallback(); + }, [location, navigate]); + + if (error) { + return ( +
+

Authentication Failed

+

Error: {error}

+ +
+ ); + } + + return ( +
+

Processing Google Login...

+

Please wait while we complete your authentication...

+
+ ); +} + +export default GoogleAuthCallback; +``` + +### 3. Example React Router Setup + +Ensure your React application's routing is set up to handle the callback URL (only needed if using the full redirect approach). + +```jsx +// Example App.js or main router file +import React from 'react'; +import { BrowserRouter as Router, Routes, Route } from 'react-router-dom'; +import GoogleLoginButton from './components/GoogleLoginButton'; +import GoogleAuthCallback from './components/GoogleAuthCallback'; +import Dashboard from './components/Dashboard'; +import LoginPage from './components/LoginPage'; + +function App() { + return ( + + + } /> + } /> + } /> + {/* Other routes */} + + + ); +} + +export default App; +``` + +### 4. Using the Authentication Tokens + +Once you have the tokens, you can use them to make authenticated requests to your API: + +```jsx +// Example of making authenticated API requests +const makeAuthenticatedRequest = async (url, options = {}) => { + const accessToken = localStorage.getItem('accessToken'); + + const response = await fetch(url, { + ...options, + headers: { + ...options.headers, + 'Authorization': `Bearer ${accessToken}`, + 'Content-Type': 'application/json', + }, + }); + + if (response.status === 401) { + // Token might be expired, try to refresh or redirect to login + localStorage.removeItem('accessToken'); + localStorage.removeItem('refreshToken'); + localStorage.removeItem('user'); + window.location.href = '/login'; + return; + } + + return response; +}; +``` + ## OAuth Flow Diagram ```mermaid graph TD - A[User] --> B{Access /api/v1/auth/google/login}; - B --> C[Backend generates Auth URL]; - C --> D{Redirect to Google Auth Page}; - D --> E[User Authenticates with Google]; - E --> F{Google Redirects to /api/v1/auth/google/callback}; - F --> G[Backend Exchanges Code for Token]; - G --> H[Backend Fetches User Info]; - H --> I{User Exists?}; - I -- Yes --> J[Retrieve User]; - I -- No --> K[Create New User]; - J --> L[Generate JWT]; - K --> L; - L --> M[Return JWT to User]; \ No newline at end of file + A[User] --> B{Click Google Login Button} + B --> C[Frontend opens popup/redirects to /api/v1/auth/google/login] + C --> D[Backend redirects to Google Auth] + D --> E[User authenticates with Google] + E --> F[Google redirects to /api/v1/auth/google/callback] + F --> G[Backend exchanges code for Google token] + G --> H[Backend fetches user info from Google] + H --> I{User exists in database?} + I -- Yes --> J[Get existing user] + I -- No --> K[Create new user with Google info] + J --> L[Generate JWT access and refresh tokens] + K --> L + L --> M[Return JSON response with tokens and user data] + M --> N[Frontend stores tokens securely] + N --> O[Redirect user to dashboard/protected area] +``` + +## Security Considerations + +1. **Token Storage**: Store tokens securely in httpOnly cookies or secure storage mechanisms rather than localStorage in production. + +2. **HTTPS Only**: Always use HTTPS in production to protect tokens in transit. + +3. **Token Expiration**: Implement proper token refresh logic when access tokens expire. + +4. **CORS Configuration**: Ensure your backend has proper CORS configuration for the frontend domain. + +5. **State Validation**: The backend validates the CSRF state parameter to prevent CSRF attacks. + +6. **Scope Limitation**: Only request necessary OAuth scopes from Google (email and profile in this case). diff --git a/imphnen-iam/src/v1/auth/google/google_oauth_controller.rs b/imphnen-iam/src/v1/auth/google/google_oauth_controller.rs index 19389a2..8fd574f 100644 --- a/imphnen-iam/src/v1/auth/google/google_oauth_controller.rs +++ b/imphnen-iam/src/v1/auth/google/google_oauth_controller.rs @@ -1,18 +1,15 @@ use axum::{ extract::{Query, State}, - response::{IntoResponse, Redirect}, + response::Redirect, routing::get, Json, Router, }; -use axum::http::StatusCode; use serde::{Deserialize, Serialize}; use utoipa::ToSchema; use std::sync::Arc; use imphnen_libs::enviroment::ENV; // Import ENV use crate::v1::auth::google::google_oauth_service::{AuthRequest, GoogleOauthService, GoogleOauthServiceImpl}; -use crate::v1::auth::auth_service::AuthServiceTrait; -use crate::v1::users::users_service::UsersServiceTrait; use imphnen_entities::error_dto::error::Error; use crate::v1::auth::AuthLoginResponsetDto; @@ -74,8 +71,12 @@ where } pub async fn google_oauth_callback(&self, auth_request: AuthRequest) -> Result, Error> { - let response = self.google_oauth_service.google_oauth_callback(auth_request).await?; - Ok(Json(response)) + let (user, token) = self.google_oauth_service.google_oauth_callback(auth_request).await?; + let auth_response = AuthLoginResponsetDto { + user, + token, + }; + Ok(Json(auth_response)) } } diff --git a/imphnen-iam/src/v1/auth/google/google_oauth_service.rs b/imphnen-iam/src/v1/auth/google/google_oauth_service.rs index c23e083..7eaae5a 100644 --- a/imphnen-iam/src/v1/auth/google/google_oauth_service.rs +++ b/imphnen-iam/src/v1/auth/google/google_oauth_service.rs @@ -9,7 +9,7 @@ use oauth2::url::Url; use imphnen_entities::error_dto::error::Error; use imphnen_libs::{jsonwebtoken::{encode_access_token, encode_refresh_token}, enviroment::Env}; -use crate::v1::auth::{AuthLoginResponsetDto, TokenDto}; +use crate::v1::auth::TokenDto; use crate::v1::auth::auth_service::AuthServiceTrait; use crate::v1::users::users_dto::{UsersCreateRequestDto, UsersDetailItemDto}; use crate::v1::users::users_service::UsersServiceTrait; @@ -28,14 +28,15 @@ pub trait GoogleOauthService Self; fn google_oauth_client(&self) -> BasicClient; fn generate_auth_url(&self) -> (Url, CsrfToken); - async fn google_oauth_callback(&self, auth_request: AuthRequest) -> Result; + async fn google_oauth_callback(&self, auth_request: AuthRequest) -> Result<(UsersDetailItemDto, TokenDto), Error>; // Changed return type } #[derive(Clone)] pub struct GoogleOauthServiceImpl { - auth_service: A, users_service: U, env: &'static Env, + #[allow(dead_code)] + auth_service: A, } impl GoogleOauthServiceImpl { @@ -88,7 +89,7 @@ where .url() } - async fn google_oauth_callback(&self, auth_request: AuthRequest) -> Result { + async fn google_oauth_callback(&self, auth_request: AuthRequest) -> Result<(UsersDetailItemDto, TokenDto), Error> { let client = self.google_oauth_client(); let token_response = client @@ -134,13 +135,11 @@ where let refresh_token = encode_refresh_token(user.email.clone()) .map_err(|e| Error::Db(format!("Failed to generate refresh token: {}", e)))?; - let response = AuthLoginResponsetDto { - user: user.clone(), // Cloned the user to satisfy potential ownership issues - token: TokenDto { - access_token, - refresh_token, - }, + let token_dto = TokenDto { + access_token, + refresh_token, }; - Ok(response) + + Ok((user, token_dto)) } } \ No newline at end of file diff --git a/imphnen-iam/src/v1/users/users_service.rs b/imphnen-iam/src/v1/users/users_service.rs index bcc7f83..d3bc6a0 100644 --- a/imphnen-iam/src/v1/users/users_service.rs +++ b/imphnen-iam/src/v1/users/users_service.rs @@ -1,5 +1,5 @@ use super::{ - UsersActiveInactiveRequestDto, UsersCreateRequestDto, UsersDetailItemDto, + UsersActiveInactiveRequestDto, UsersCreateRequestDto, UsersSetNewPasswordRequestDto, UsersUpdateRequestDto, }; use crate::{ @@ -11,12 +11,12 @@ use crate::{ }; use axum::http::HeaderMap; use axum::{http::StatusCode, response::Response}; -use imphnen_libs::{ResourceEnum, hash_password, verify_password}; +use imphnen_libs::{ResourceEnum, hash_password, verify_password, surrealdb_init_ws, surrealdb_init_mem}; use imphnen_utils::make_thing; use uuid::Uuid; use anyhow::Result; use async_trait::async_trait; -use crate::v1::users::users_dto::{UsersDetailItemDto as UserDto, UsersCreateRequestDto as CreateUserDto, UsersDetailQueryDto}; +use crate::v1::users::users_dto::{UsersDetailItemDto as UserDto, UsersCreateRequestDto as CreateUserDto}; #[async_trait] pub trait UsersServiceTrait: Send + Sync + 'static { @@ -250,10 +250,16 @@ impl UsersServiceTrait for UsersService { } } + #[allow(unused_variables)] async fn get_user_by_email(&self, email: &str) -> Result> { + let surrealdb_ws = surrealdb_init_ws().await + .map_err(|e| anyhow::anyhow!("Failed to initialize websocket database: {}", e))?; + let surrealdb_mem = surrealdb_init_mem().await + .map_err(|e| anyhow::anyhow!("Failed to initialize memory database: {}", e))?; + let state = AppState { - surrealdb_ws: todo!(), - surrealdb_mem: todo!(), + surrealdb_ws, + surrealdb_mem, }; let repo = UsersRepository::new(&state); let user = repo.query_user_by_email(email.to_string()).await; @@ -264,12 +270,19 @@ impl UsersServiceTrait for UsersService { } } + #[allow(unused_variables)] async fn create_user_by_dto(&self, new_user: CreateUserDto) -> Result { + let surrealdb_ws = surrealdb_init_ws().await + .map_err(|e| anyhow::anyhow!("Failed to initialize websocket database: {}", e))?; + let surrealdb_mem = surrealdb_init_mem().await + .map_err(|e| anyhow::anyhow!("Failed to initialize memory database: {}", e))?; + let state = AppState { - surrealdb_ws: todo!(), - surrealdb_mem: todo!(), + surrealdb_ws, + surrealdb_mem, }; let repo = UsersRepository::new(&state); + let email_clone = new_user.email.clone(); // Store email before moving new_user let user_schema = UsersSchema { email: new_user.email, password: new_user.password, // No unwrap_or_default needed @@ -280,9 +293,9 @@ impl UsersServiceTrait for UsersService { ..Default::default() }; match repo.query_create_user(user_schema).await { - Ok(msg) => { // msg is String, not UsersDetailQueryDto + Ok(_msg) => { // msg is String, not UsersDetailQueryDto // Re-fetch the created user to get the full UsersDetailQueryDto - let created_user = repo.query_user_by_email(new_user.email.clone()).await?; // Cloned email + let created_user = repo.query_user_by_email(email_clone).await?; // Use cloned email Ok(UserDto::from(&created_user)) // Corrected to use UserDto::from by reference }, Err(e) => Err(anyhow::anyhow!(e.to_string())), diff --git a/tests/src/iam/auth/google/google_oauth_flow_test.rs b/tests/src/iam/auth/google/google_oauth_flow_test.rs index 758e14f..d315ec0 100644 --- a/tests/src/iam/auth/google/google_oauth_flow_test.rs +++ b/tests/src/iam/auth/google/google_oauth_flow_test.rs @@ -26,7 +26,7 @@ mod tests { fn with_services(auth_service: crate::v1::auth::auth_service::AuthService, users_service: crate::v1::users::users_service::UsersService, env: &'static Env) -> Self; // Updated signature fn google_oauth_client(&self) -> oauth2::basic::BasicClient; fn generate_auth_url(&self) -> (url::Url, oauth2::CsrfToken); - async fn google_oauth_callback(&self, auth_request: AuthRequest) -> anyhow::Result; + async fn google_oauth_callback(&self, auth_request: AuthRequest) -> anyhow::Result; } }