diff --git a/packages/backend/src/routes/auth/index.ts b/packages/backend/src/routes/auth/index.ts index 1c0c2d2..d2a7d45 100644 --- a/packages/backend/src/routes/auth/index.ts +++ b/packages/backend/src/routes/auth/index.ts @@ -12,6 +12,14 @@ import { createJwtAuthMiddleware } from '../../middleware/jwt-auth.middleware'; import type { AppServiceMap } from '../../services'; import type { ServiceProvider } from '../../services/app.services'; +// OAuth state data structure +interface OAuthStateData { + device_code?: string; + desktop?: boolean; + callback?: string; + include_provider_token?: boolean; +} + export const getAuthRouter = (serviceProvider: ServiceProvider) => { const router = Router(); @@ -38,14 +46,27 @@ export const getAuthRouter = (serviceProvider: ServiceProvider) = // Store device code in session state if coming from device flow const deviceUserCode = req.query.device_code as string | undefined; + // Desktop OAuth parameters + const desktop = req.query.desktop === 'true'; + const callback = req.query.callback as string | undefined; + const includeProviderToken = req.query.include_provider_token === 'true'; + logger.info('GitHub OAuth initiated', { ip: req.ip, userAgent: req.get('User-Agent'), isDeviceFlow: !!deviceUserCode, + isDesktop: desktop, }); - // Pass device code via state parameter - const state = deviceUserCode ? JSON.stringify({ device_code: deviceUserCode }) : undefined; + // Build state parameter with all flow information + const stateData: OAuthStateData = {}; + if (deviceUserCode) stateData.device_code = deviceUserCode; + if (desktop) { + stateData.desktop = true; + stateData.callback = callback; + stateData.include_provider_token = includeProviderToken; + } + const state = Object.keys(stateData).length > 0 ? JSON.stringify(stateData) : undefined; passport.authenticate('github', { scope: ['user:email'], @@ -81,25 +102,58 @@ export const getAuthRouter = (serviceProvider: ServiceProvider) = email: req.user.email, }); - // Check if this is from device flow - let deviceUserCode: string | undefined; + // Parse state parameter for flow information + let stateData: OAuthStateData = {}; try { const state = req.query.state as string; if (state) { - const stateData = JSON.parse(state); - deviceUserCode = stateData.device_code; + stateData = JSON.parse(state) as OAuthStateData; } } catch { - // Not a device flow or invalid state + // Invalid or missing state } const frontendFqdn = config.get('FRONTEND_FQDN', 'localhost:3000'); const protocol = process.env.NODE_ENV === 'production' ? 'https' : 'http'; - if (deviceUserCode) { + // Check for desktop flow + if (stateData.desktop && stateData.callback) { + // Validate callback is localhost + try { + const callbackUrl = new URL(stateData.callback); + if (callbackUrl.hostname !== 'localhost' && callbackUrl.hostname !== '127.0.0.1') { + logger.warn('Invalid desktop callback URL - not localhost', { + callback: stateData.callback, + }); + return res.status(400).json({ error: 'Invalid callback URL - must be localhost' }); + } + + // Build redirect URL with token + callbackUrl.searchParams.set('token', token); + + // Include provider token if requested + if (stateData.include_provider_token && req.user.providerToken) { + callbackUrl.searchParams.set('github_token', req.user.providerToken); + } + + logger.info('Redirecting to desktop callback', { callback: callbackUrl.toString() }); + return res.redirect(callbackUrl.toString()); + } catch (error) { + logger.error('Error parsing desktop callback URL', { + error, + callback: stateData.callback, + }); + return res.status(400).json({ error: 'Invalid callback URL format' }); + } + } + + // Check if this is from device flow + if (stateData.device_code) { // Redirect to device authorization page with token - const redirectUrl = `${protocol}://${frontendFqdn}/device/authorize?token=${token}&code=${deviceUserCode}`; - logger.info('Redirecting to device authorization', { deviceUserCode }); + const redirectUrl = `${protocol}://${frontendFqdn}/device/authorize?token=${token}&code=${stateData.device_code}`; + logger.info('Redirecting to device authorization', { + deviceUserCode: stateData.device_code, + }); return res.redirect(redirectUrl); } @@ -119,12 +173,31 @@ export const getAuthRouter = (serviceProvider: ServiceProvider) = router.get('/google', async (req: Request, res: Response, next) => { try { const logger = await serviceProvider.get('logger'); + + // Desktop OAuth parameters + const desktop = req.query.desktop === 'true'; + const callback = req.query.callback as string | undefined; + const includeProviderToken = req.query.include_provider_token === 'true'; + logger.info('Google OAuth initiated', { ip: req.ip, userAgent: req.get('User-Agent'), + isDesktop: desktop, }); - passport.authenticate('google', { scope: ['profile', 'email'] })(req, res, next); + // Build state parameter with desktop flow information + const stateData: OAuthStateData = {}; + if (desktop) { + stateData.desktop = true; + stateData.callback = callback; + stateData.include_provider_token = includeProviderToken; + } + const state = Object.keys(stateData).length > 0 ? JSON.stringify(stateData) : undefined; + + passport.authenticate('google', { + scope: ['profile', 'email'], + state, + })(req, res, next); } catch (error) { next(error); } @@ -155,9 +228,52 @@ export const getAuthRouter = (serviceProvider: ServiceProvider) = email: req.user.email, }); - // Redirect to frontend with token + // Parse state parameter for flow information + let stateData: OAuthStateData = {}; + try { + const state = req.query.state as string; + if (state) { + stateData = JSON.parse(state) as OAuthStateData; + } + } catch { + // Invalid or missing state + } + const frontendFqdn = config.get('FRONTEND_FQDN', 'localhost:3000'); const protocol = process.env.NODE_ENV === 'production' ? 'https' : 'http'; + + // Check for desktop flow + if (stateData.desktop && stateData.callback) { + // Validate callback is localhost + try { + const callbackUrl = new URL(stateData.callback); + if (callbackUrl.hostname !== 'localhost' && callbackUrl.hostname !== '127.0.0.1') { + logger.warn('Invalid desktop callback URL - not localhost', { + callback: stateData.callback, + }); + return res.status(400).json({ error: 'Invalid callback URL - must be localhost' }); + } + + // Build redirect URL with token + callbackUrl.searchParams.set('token', token); + + // Include provider token if requested + if (stateData.include_provider_token && req.user.providerToken) { + callbackUrl.searchParams.set('google_token', req.user.providerToken); + } + + logger.info('Redirecting to desktop callback', { callback: callbackUrl.toString() }); + return res.redirect(callbackUrl.toString()); + } catch (error) { + logger.error('Error parsing desktop callback URL', { + error, + callback: stateData.callback, + }); + return res.status(400).json({ error: 'Invalid callback URL format' }); + } + } + + // Redirect to frontend with token const redirectUrl = `${protocol}://${frontendFqdn}/auth/callback?token=${token}`; res.redirect(redirectUrl); } catch (error) { @@ -173,12 +289,31 @@ export const getAuthRouter = (serviceProvider: ServiceProvider) = router.get('/microsoft', async (req: Request, res: Response, next) => { try { const logger = await serviceProvider.get('logger'); + + // Desktop OAuth parameters + const desktop = req.query.desktop === 'true'; + const callback = req.query.callback as string | undefined; + const includeProviderToken = req.query.include_provider_token === 'true'; + logger.info('Microsoft OAuth initiated', { ip: req.ip, userAgent: req.get('User-Agent'), + isDesktop: desktop, }); - passport.authenticate('microsoft', { scope: ['user.read'] })(req, res, next); + // Build state parameter with desktop flow information + const stateData: OAuthStateData = {}; + if (desktop) { + stateData.desktop = true; + stateData.callback = callback; + stateData.include_provider_token = includeProviderToken; + } + const state = Object.keys(stateData).length > 0 ? JSON.stringify(stateData) : undefined; + + passport.authenticate('microsoft', { + scope: ['user.read'], + state, + })(req, res, next); } catch (error) { next(error); } @@ -209,9 +344,52 @@ export const getAuthRouter = (serviceProvider: ServiceProvider) = email: req.user.email, }); - // Redirect to frontend with token + // Parse state parameter for flow information + let stateData: OAuthStateData = {}; + try { + const state = req.query.state as string; + if (state) { + stateData = JSON.parse(state) as OAuthStateData; + } + } catch { + // Invalid or missing state + } + const frontendFqdn = config.get('FRONTEND_FQDN', 'localhost:3000'); const protocol = process.env.NODE_ENV === 'production' ? 'https' : 'http'; + + // Check for desktop flow + if (stateData.desktop && stateData.callback) { + // Validate callback is localhost + try { + const callbackUrl = new URL(stateData.callback); + if (callbackUrl.hostname !== 'localhost' && callbackUrl.hostname !== '127.0.0.1') { + logger.warn('Invalid desktop callback URL - not localhost', { + callback: stateData.callback, + }); + return res.status(400).json({ error: 'Invalid callback URL - must be localhost' }); + } + + // Build redirect URL with token + callbackUrl.searchParams.set('token', token); + + // Include provider token if requested + if (stateData.include_provider_token && req.user.providerToken) { + callbackUrl.searchParams.set('microsoft_token', req.user.providerToken); + } + + logger.info('Redirecting to desktop callback', { callback: callbackUrl.toString() }); + return res.redirect(callbackUrl.toString()); + } catch (error) { + logger.error('Error parsing desktop callback URL', { + error, + callback: stateData.callback, + }); + return res.status(400).json({ error: 'Invalid callback URL format' }); + } + } + + // Redirect to frontend with token const redirectUrl = `${protocol}://${frontendFqdn}/auth/callback?token=${token}`; res.redirect(redirectUrl); } catch (error) { diff --git a/packages/backend/src/services/oauth/oauth.service.ts b/packages/backend/src/services/oauth/oauth.service.ts index 4e5018d..8787b7d 100644 --- a/packages/backend/src/services/oauth/oauth.service.ts +++ b/packages/backend/src/services/oauth/oauth.service.ts @@ -1,10 +1,18 @@ import passport from 'passport'; -import { Strategy as GitHubStrategy } from 'passport-github2'; -import { Strategy as GoogleStrategy } from 'passport-google-oauth20'; +import { Profile as GitHubProfile, Strategy as GitHubStrategy } from 'passport-github2'; +import { Profile as GoogleProfile, Strategy as GoogleStrategy } from 'passport-google-oauth20'; import { Strategy as MicrosoftStrategy } from 'passport-microsoft'; import type { ConfigService, LoggerService, Service } from '../app.services'; import type { UserService } from '../user'; +// Microsoft profile interface (passport-microsoft doesn't export Profile type) +interface MicrosoftProfile { + id: string; + displayName?: string; + emails?: Array<{ value: string }>; + photos?: Array<{ value: string }>; +} + export interface OAuthService extends Service { /** * Configure all OAuth strategies @@ -40,9 +48,9 @@ export const createOAuthService = ( scope: ['user:email'], }, async ( - _accessToken: string, + accessToken: string, _refreshToken: string, - profile: any, + profile: GitHubProfile, done: (error: Error | null, user?: Express.User | false) => void ) => { try { @@ -71,6 +79,7 @@ export const createOAuthService = ( role: userDoc.role, createdAt: userDoc.createdAt, updatedAt: userDoc.updatedAt, + providerToken: accessToken, // Store provider token for desktop flow }; logger.info('GitHub OAuth successful', { @@ -107,7 +116,12 @@ export const createOAuthService = ( callbackURL: googleCallbackUrl, scope: ['profile', 'email'], }, - async (_accessToken, _refreshToken, profile, done) => { + async ( + accessToken: string, + _refreshToken: string, + profile: GoogleProfile, + done: (error: Error | null, user?: Express.User | false) => void + ) => { try { const email = profile.emails?.[0]?.value; if (!email) { @@ -133,6 +147,7 @@ export const createOAuthService = ( role: userDoc.role, createdAt: userDoc.createdAt, updatedAt: userDoc.updatedAt, + providerToken: accessToken, // Store provider token for desktop flow }; logger.info('Google OAuth successful', { @@ -175,9 +190,9 @@ export const createOAuthService = ( scope: ['user.read'], }, async ( - _accessToken: string, + accessToken: string, _refreshToken: string, - profile: any, + profile: MicrosoftProfile, done: (error: Error | null, user?: Express.User | false) => void ) => { try { @@ -205,6 +220,7 @@ export const createOAuthService = ( role: userDoc.role, createdAt: userDoc.createdAt, updatedAt: userDoc.updatedAt, + providerToken: accessToken, // Store provider token for desktop flow }; logger.info('Microsoft OAuth successful', { diff --git a/packages/backend/src/types/express.d.ts b/packages/backend/src/types/express.d.ts index 80f17f8..5c48652 100644 --- a/packages/backend/src/types/express.d.ts +++ b/packages/backend/src/types/express.d.ts @@ -39,6 +39,9 @@ declare global { }; }; + // Provider token (for desktop OAuth flow) + providerToken?: string; + // Authorization role: 'user' | 'admin'; diff --git a/packages/frontend/src/app/desktop-login/page.tsx b/packages/frontend/src/app/desktop-login/page.tsx new file mode 100644 index 0000000..bd84071 --- /dev/null +++ b/packages/frontend/src/app/desktop-login/page.tsx @@ -0,0 +1,116 @@ +'use client'; + +import Link from 'next/link'; +import { useSearchParams } from 'next/navigation'; +import { Suspense } from 'react'; +import { LoginButton } from '../../components/auth/LoginButton'; + +function DesktopLoginContent() { + const searchParams = useSearchParams(); + const callback = searchParams.get('callback') || 'http://localhost:3739/callback'; + + const handleLogin = (provider: 'google' | 'github' | 'microsoft') => { + const params = new URLSearchParams({ + desktop: 'true', + callback, + include_provider_token: 'true', + }); + + window.location.href = `/api/auth/${provider}?${params.toString()}`; + }; + + return ( +
+
+ {/* Header */} +
+
+ + + +
+

Sign in to Agentage Desktop

+

Choose your preferred authentication method

+
+ + {/* Login Buttons */} +
+ handleLogin('google')} className="w-full" /> + handleLogin('github')} className="w-full" /> + handleLogin('microsoft')} + className="w-full" + /> +
+ + {/* Info Box */} +
+
+ + + +
+

+ Secure Desktop Authentication +

+

+ After signing in, you'll be redirected back to the Agentage Desktop application on + your computer. +

+
+
+
+ + {/* Terms */} +

+ By signing in, you agree to our{' '} + + Terms of Service + {' '} + and{' '} + + Privacy Policy + +

+
+
+ ); +} + +export default function DesktopLoginPage() { + return ( + +
+
+

Loading...

+
+ + } + > + +
+ ); +}