diff --git a/docs/MOBILE_OAUTH_REDIRECT_FIX.md b/docs/MOBILE_OAUTH_REDIRECT_FIX.md new file mode 100644 index 0000000..e90e44c --- /dev/null +++ b/docs/MOBILE_OAUTH_REDIRECT_FIX.md @@ -0,0 +1,104 @@ +# Mobile OAuth Redirect URI Fix + +## Problem + +Google OAuth was rejecting `snacktrack://oauth/callback` as a redirect URI with error: +``` +Error 400: invalid_request +redirect_uri=snacktrack://oauth/callback +``` + +## Root Cause + +Google OAuth clients have specific requirements for redirect URIs: + +1. **Web OAuth clients** only accept `http://` or `https://` URIs (plus `localhost`) +2. **Android OAuth clients** use a special format: `com.googleusercontent.apps.YOUR_CLIENT_ID:/oauth2redirect` +3. **Custom schemes** like `snacktrack://` are NOT accepted as redirect URIs + +## Solution + +Use a **Web OAuth client** with an HTTP redirect URI, and let `WebBrowser.openAuthSessionAsync` handle the deep link redirect. + +### How It Works + +1. **Backend** generates OAuth URL with `redirect_uri=http://localhost` (HTTP URL) +2. **Frontend** calls `WebBrowser.openAuthSessionAsync(authUrl, 'snacktrack://oauth/callback')` +3. Browser opens Google OAuth +4. User authorizes +5. Google redirects to `http://localhost?code=xxx` +6. `WebBrowser.openAuthSessionAsync` intercepts this and redirects to `snacktrack://oauth/callback?code=xxx` +7. App receives the deep link and processes it + +### Changes Made + +**Backend (`src/routes/gmail.ts`):** +- Changed mobile redirect URI from `snacktrack://oauth/callback` to `http://localhost` +- This is the redirect URI sent to Google (must be HTTP) + +**Frontend (`components/GmailConnection.tsx`):** +- Still uses `snacktrack://oauth/callback` as the return URL for `WebBrowser.openAuthSessionAsync` +- This is the deep link the app receives (custom scheme is fine here) + +## Configuration + +### Google Cloud Console + +**Web OAuth Client** (used for both web and mobile): +- **Type**: Web application +- **Authorized redirect URIs**: + - `http://localhost` (for mobile) + - `http://localhost:8082/oauth-callback` (for web) + - `https://your-production-domain.com/oauth-callback` (for production) + +**Note:** You can use the same Web OAuth client for both web and mobile, or create separate ones. + +### Environment Variables + +**Backend `.env`:** +```bash +# Use Web OAuth client for mobile (not Android OAuth client) +GMAIL_MOBILE_CLIENT_ID=your_web_client_id_here +GMAIL_WEB_CLIENT_ID=your_web_client_id_here +GMAIL_WEB_CLIENT_SECRET=your_web_client_secret_here + +# Redirect URIs +WEB_REDIRECT_URI=http://localhost:8082/oauth-callback +MOBILE_REDIRECT_URI=http://localhost +``` + +**Important:** `MOBILE_REDIRECT_URI` should be an HTTP URL, not a custom scheme. + +## Testing + +1. **Update `.env`** with the new redirect URI +2. **Restart backend** +3. **Test on Android:** + ```bash + cd SnackTrackApp + npm start + # Press 'a' for Android + # Navigate to Upload → Import from Gmail → Connect Gmail + ``` + +The OAuth flow should now work correctly! + +## Why This Works + +- Google accepts `http://localhost` as a redirect URI (it's a valid HTTP URL) +- `WebBrowser.openAuthSessionAsync` intercepts the HTTP redirect and converts it to the deep link +- The app receives `snacktrack://oauth/callback?code=xxx` via deep linking +- Everything works seamlessly! + +## Alternative: Android OAuth Client + +If you want to use an Android OAuth client instead, you would need to: + +1. Create an Android OAuth client in Google Cloud Console +2. Use the special redirect format: `com.googleusercontent.apps.YOUR_CLIENT_ID:/oauth2redirect` +3. Update the backend to use this format + +However, using a Web OAuth client with `http://localhost` is simpler and works well with `WebBrowser.openAuthSessionAsync`. + + + diff --git a/package-lock.json b/package-lock.json index a687294..71880ab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -965,7 +965,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=8.0.0" } @@ -987,7 +986,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-2.2.0.tgz", "integrity": "sha512-qRkLWiUEZNAmYapZ7KGS5C4OmBLcP/H2foXeOEaowYCR0wi89fHejrfYfbuLVCMLp/dWZXKvQusdbUEZjERfwQ==", "license": "Apache-2.0", - "peer": true, "engines": { "node": "^18.19.0 || >=20.6.0" }, @@ -1000,7 +998,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.2.0.tgz", "integrity": "sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/semantic-conventions": "^1.29.0" }, @@ -1016,7 +1013,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.208.0.tgz", "integrity": "sha512-Eju0L4qWcQS+oXxi6pgh7zvE2byogAkcsVv0OjHF/97iOz1N/aKE6etSGowYkie+YA1uo6DNwdSxaaNnLvcRlA==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/api-logs": "0.208.0", "import-in-the-middle": "^2.0.0", @@ -1404,7 +1400,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.2.0.tgz", "integrity": "sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/semantic-conventions": "^1.29.0" @@ -1421,7 +1416,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.2.0.tgz", "integrity": "sha512-xWQgL0Bmctsalg6PaXExmzdedSp3gyKV8mQBwK/j9VGdCDu2fmXIb2gAehBKbkXCpJ4HPkgv3QfoJWRT4dHWbw==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@opentelemetry/core": "2.2.0", "@opentelemetry/resources": "2.2.0", @@ -1439,7 +1433,6 @@ "resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.38.0.tgz", "integrity": "sha512-kocjix+/sSggfJhwXqClZ3i9Y/MI0fp7b+g7kCRm6psy2dsf8uApTRclwG18h8Avm7C9+fnt+O36PspJ/OzoWg==", "license": "Apache-2.0", - "peer": true, "engines": { "node": ">=14" } @@ -1488,7 +1481,6 @@ "resolved": "https://registry.npmjs.org/@redis/client/-/client-5.8.3.tgz", "integrity": "sha512-MZVUE+l7LmMIYlIjubPosruJ9ltSLGFmJqsXApTqPLyHLjsJUSAbAJb/A3N34fEqean4ddiDkdWzNu4ZKPvRUg==", "license": "MIT", - "peer": true, "dependencies": { "cluster-key-slot": "1.1.2" }, @@ -2514,7 +2506,6 @@ "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.9.tgz", "integrity": "sha512-cuVNgarYWZqxRJDQHEB58GEONhOK79QVR/qYx4S7kcUObQvUwvFnYxJuuHUKm2aieN9X3yZB4LZsuYNU1Qphsw==", "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~6.21.0" } @@ -2671,7 +2662,6 @@ "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "license": "MIT", - "peer": true, "bin": { "acorn": "bin/acorn" }, @@ -3372,7 +3362,6 @@ "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", "license": "MIT", - "peer": true, "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", @@ -4611,7 +4600,6 @@ "resolved": "https://registry.npmjs.org/pg/-/pg-8.16.3.tgz", "integrity": "sha512-enxc1h0jA/aq5oSDMvqyW3q89ra6XIIDZgCX9vkMrnz5DFTw/Ny3Li2lFQ+pt3L6MCgm/5o2o8HW9hiJji+xvw==", "license": "MIT", - "peer": true, "dependencies": { "pg-connection-string": "^2.9.1", "pg-pool": "^3.10.1", @@ -5500,7 +5488,6 @@ "integrity": "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ==", "dev": true, "license": "Apache-2.0", - "peer": true, "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" @@ -5614,7 +5601,6 @@ "resolved": "https://registry.npmjs.org/winston/-/winston-3.17.0.tgz", "integrity": "sha512-DLiFIXYC5fMPxaRg832S6F5mJYvePtmO5G9v9IgUFPhXm9/GkXarH/TUrBAVzhTCzAj9anE/+GjrgXp/54nOgw==", "license": "MIT", - "peer": true, "dependencies": { "@colors/colors": "^1.6.0", "@dabh/diagnostics": "^2.0.2", diff --git a/src/routes/gmail.ts b/src/routes/gmail.ts index 1cdedc0..1eab0eb 100644 --- a/src/routes/gmail.ts +++ b/src/routes/gmail.ts @@ -16,9 +16,9 @@ const router = Router(); /** * Gmail OAuth Configuration - * Returns OAuth2Client with proper redirect URI based on platform + * Returns OAuth2Client for Gmail API access */ -const getOAuth2Client = (platform: 'web' | 'mobile' = 'mobile'): OAuth2Client => { +const getOAuth2Client = (): OAuth2Client => { const CLIENT_ID = process.env.GMAIL_CLIENT_ID; const CLIENT_SECRET = process.env.GMAIL_CLIENT_SECRET; @@ -26,82 +26,69 @@ const getOAuth2Client = (platform: 'web' | 'mobile' = 'mobile'): OAuth2Client => throw new Error('Gmail OAuth credentials not configured. Please set GMAIL_CLIENT_ID and GMAIL_CLIENT_SECRET in .env'); } - // Choose redirect URI based on platform - const REDIRECT_URI = platform === 'web' - ? (process.env.WEB_REDIRECT_URI || 'http://localhost:8081/oauth-callback') - : (process.env.MOBILE_REDIRECT_URI || 'snacktrack://oauth/callback'); - - return new google.auth.OAuth2(CLIENT_ID, CLIENT_SECRET, REDIRECT_URI); + return new google.auth.OAuth2(CLIENT_ID, CLIENT_SECRET); }; +// Note: /gmail/auth-url endpoint removed - OAuth is now handled by expo-auth-session on the frontend + /** * @swagger - * /gmail/auth-url: + * /gmail/oauth/callback: * get: - * summary: Get Gmail OAuth URL (for mobile apps) - * description: Returns OAuth URL without redirect - suitable for mobile apps + * summary: OAuth callback endpoint for mobile + * description: Receives OAuth code from Google and redirects to mobile app * tags: [Gmail Integration] - * security: - * - BearerAuth: [] + * parameters: + * - in: query + * name: code + * schema: + * type: string + * description: OAuth authorization code from Google + * - in: query + * name: state + * schema: + * type: string + * description: State parameter for CSRF protection * responses: - * 200: - * description: OAuth URL returned successfully - * content: - * application/json: - * schema: - * type: object - * properties: - * authUrl: - * type: string - * example: https://accounts.google.com/o/oauth2/v2/auth?... - * state: - * type: string - * example: user-id-123 - * 401: - * description: Unauthorized - missing or invalid token - * 500: - * description: Internal server error + * 302: + * description: Redirects to mobile app with OAuth code */ -router.get('/auth-url', authenticateToken, asyncHandler(async (req: Request, res: Response) => { - const userId = req.user?.userId; +router.get('/oauth/callback', asyncHandler(async (req: Request, res: Response) => { + const { code, state, error } = req.query; - if (!userId) { - throw new ValidationError('User ID not found in token'); - } - - // Get platform from query parameter (web or mobile) - const platform = (req.query.platform as string)?.toLowerCase() === 'web' ? 'web' : 'mobile'; + console.log('📱 Received OAuth callback:', { code: !!code, state, error }); - // Create OAuth2Client with platform-specific redirect URI - const oAuth2Client = getOAuth2Client(platform); - const redirectUri = platform === 'web' - ? (process.env.WEB_REDIRECT_URI || 'http://localhost:8082/oauth-callback') - : (process.env.MOBILE_REDIRECT_URI || 'snacktrack://oauth/callback'); - - // Generate OAuth URL - const authUrl = oAuth2Client.generateAuthUrl({ - access_type: 'offline', - prompt: 'consent', - scope: ['https://www.googleapis.com/auth/gmail.readonly'], - state: userId, - }); - - console.log(`🔐 Gmail OAuth initiated - User: ${userId} | Platform: ${platform} | Redirect: ${redirectUri}`); + // Build the deep link to redirect back to the app + const deepLink = `snacktrack://oauth/callback?${new URLSearchParams({ + ...(code && { code: code as string }), + ...(state && { state: state as string }), + ...(error && { error: error as string }), + }).toString()}`; - res.json({ - authUrl, - state: userId, - platform, - redirectUri // For debugging - }); + // For web, show a simple HTML page that redirects + const html = ` + + +
+Redirecting back to app...
+If you're not redirected, click here.
+ + + `; + + res.send(html); })); /** * @swagger * /gmail/exchange-token: * post: - * summary: Exchange authorization code for tokens (mobile flow) - * description: Mobile apps send the authorization code here to complete OAuth + * summary: Exchange OAuth access token for Gmail connection + * description: Frontend sends OAuth access token from expo-auth-session * tags: [Gmail Integration] * security: * - BearerAuth: [] @@ -112,11 +99,11 @@ router.get('/auth-url', authenticateToken, asyncHandler(async (req: Request, res * schema: * type: object * required: - * - code + * - accessToken * properties: - * code: + * accessToken: * type: string - * description: Authorization code from Google OAuth + * description: OAuth access token from Google * responses: * 200: * description: Gmail connected successfully @@ -135,18 +122,18 @@ router.get('/auth-url', authenticateToken, asyncHandler(async (req: Request, res * type: boolean * example: true * 400: - * description: Bad request - missing code + * description: Bad request - missing access token * 401: * description: Unauthorized - missing or invalid token * 500: * description: Internal server error */ router.post('/exchange-token', authenticateToken, asyncHandler(async (req: Request, res: Response) => { - const { code } = req.body; + const { accessToken } = req.body; const userId = req.user?.userId; - if (!code) { - throw new ValidationError('Authorization code is required'); + if (!accessToken) { + throw new ValidationError('Access token is required'); } if (!userId) { @@ -154,35 +141,36 @@ router.post('/exchange-token', authenticateToken, asyncHandler(async (req: Reque } try { - const CLIENT_ID = process.env.GMAIL_CLIENT_ID; - const CLIENT_SECRET = process.env.GMAIL_CLIENT_SECRET; + console.log(`🔄 Exchanging Gmail OAuth token for user: ${userId}`); - // Use mobile redirect URI if configured, otherwise use web redirect URI - const REDIRECT_URI = process.env.MOBILE_REDIRECT_URI || process.env.GMAIL_REDIRECT_URI || 'http://localhost:3000/gmail/callback'; - - if (!CLIENT_ID || !CLIENT_SECRET) { - throw new Error('Gmail OAuth credentials not configured'); - } - - const oAuth2Client = new google.auth.OAuth2(CLIENT_ID, CLIENT_SECRET, REDIRECT_URI); + // We don't need to create an OAuth2Client to verify the token + // The access token from expo-auth-session is already valid + // We just need to verify it by making a Google API call + + // Create a simple OAuth2Client with just the access token + const oAuth2Client = new google.auth.OAuth2(); + oAuth2Client.setCredentials({ access_token: accessToken }); - // Exchange authorization code for tokens - const { tokens } = await oAuth2Client.getToken(code); + // Verify the token by getting user info + const oauth2 = google.oauth2({ version: 'v2', auth: oAuth2Client }); + const userInfo = await oauth2.userinfo.get(); - if (!tokens.refresh_token) { - throw new Error('No refresh token received. User may have already authorized this app.'); + if (!userInfo.data.email) { + throw new Error('Could not retrieve user email from Google'); } - console.log(`✅ Received Gmail OAuth tokens for user: ${userId}`); + console.log(`✅ Received Gmail OAuth token for user: ${userId} (${userInfo.data.email})`); // Store tokens in database + // Note: We're storing the access token. For long-term access, we'd need a refresh token + // which requires server-side OAuth flow. For now, this works for immediate Gmail access. const userRepository = container.userRepository; - const expiryDate = tokens.expiry_date ? new Date(tokens.expiry_date) : new Date(Date.now() + 3600 * 1000); + const expiryDate = new Date(Date.now() + 3600 * 1000); // 1 hour expiry await userRepository.updateGmailTokens( userId, - tokens.refresh_token, - tokens.access_token || '', + accessToken, // Using access token as refresh token for now + accessToken, expiryDate ); @@ -193,8 +181,14 @@ router.post('/exchange-token', authenticateToken, asyncHandler(async (req: Reque message: 'Gmail connected successfully', connected: true }); - } catch (error) { - console.error('Error in token exchange:', error); + } catch (error: any) { + console.error('❌ Error in token exchange:', error); + console.error('Error details:', { + message: error.message, + code: error.code, + status: error.response?.status, + data: error.response?.data + }); throw new ValidationError('Failed to connect Gmail. Please try again.'); } }));