From 1549ef5f3e353926ce7089781b2126066341d72d Mon Sep 17 00:00:00 2001 From: Volodymyr Vreshch Date: Fri, 12 Dec 2025 02:15:38 +0100 Subject: [PATCH 1/3] feat: implement desktop OAuth support - Add desktop mode parameters (desktop, callback, include_provider_token) to OAuth routes - Update OAuth callbacks to redirect to localhost when in desktop mode - Capture and pass provider access tokens for desktop flow - Add providerToken field to Express.User type - Create /desktop-login frontend page for desktop authentication - Add localhost-only validation for security Implements requirements from specs/desktop-oauth.md --- packages/backend/src/routes/auth/index.ts | 198 ++++++++++++++++-- .../src/services/oauth/oauth.service.ts | 9 +- packages/backend/src/types/express.d.ts | 3 + .../frontend/src/app/desktop-login/page.tsx | 116 ++++++++++ 4 files changed, 309 insertions(+), 17 deletions(-) create mode 100644 packages/frontend/src/app/desktop-login/page.tsx diff --git a/packages/backend/src/routes/auth/index.ts b/packages/backend/src/routes/auth/index.ts index 1c0c2d2..cbcc9fb 100644 --- a/packages/backend/src/routes/auth/index.ts +++ b/packages/backend/src/routes/auth/index.ts @@ -38,14 +38,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: any = {}; + 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 +94,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: any = {}; try { const state = req.query.state as string; if (state) { - const stateData = JSON.parse(state); - deviceUserCode = stateData.device_code; + stateData = JSON.parse(state); } } 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 +165,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: any = {}; + 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 +220,52 @@ export const getAuthRouter = (serviceProvider: ServiceProvider) = email: req.user.email, }); - // Redirect to frontend with token + // Parse state parameter for flow information + let stateData: any = {}; + try { + const state = req.query.state as string; + if (state) { + stateData = JSON.parse(state); + } + } 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 +281,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: any = {}; + 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 +336,52 @@ export const getAuthRouter = (serviceProvider: ServiceProvider) = email: req.user.email, }); - // Redirect to frontend with token + // Parse state parameter for flow information + let stateData: any = {}; + try { + const state = req.query.state as string; + if (state) { + stateData = JSON.parse(state); + } + } 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..f0c5ce0 100644 --- a/packages/backend/src/services/oauth/oauth.service.ts +++ b/packages/backend/src/services/oauth/oauth.service.ts @@ -40,7 +40,7 @@ export const createOAuthService = ( scope: ['user:email'], }, async ( - _accessToken: string, + accessToken: string, _refreshToken: string, profile: any, done: (error: Error | null, user?: Express.User | false) => void @@ -71,6 +71,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 +108,7 @@ export const createOAuthService = ( callbackURL: googleCallbackUrl, scope: ['profile', 'email'], }, - async (_accessToken, _refreshToken, profile, done) => { + async (accessToken, _refreshToken, profile, done) => { try { const email = profile.emails?.[0]?.value; if (!email) { @@ -133,6 +134,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,7 +177,7 @@ export const createOAuthService = ( scope: ['user.read'], }, async ( - _accessToken: string, + accessToken: string, _refreshToken: string, profile: any, done: (error: Error | null, user?: Express.User | false) => void @@ -205,6 +207,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...

+
+ + } + > + +
+ ); +} From 4f471159e952f9ff26e455649730f5ce3a7e7e6e Mon Sep 17 00:00:00 2001 From: Volodymyr Vreshch Date: Fri, 12 Dec 2025 02:17:26 +0100 Subject: [PATCH 2/3] fix: replace any types with proper TypeScript interfaces - Add OAuthStateData interface for OAuth state parameter - Import and use Profile types from passport strategies - Fix all eslint @typescript-eslint/no-explicit-any warnings --- packages/backend/src/routes/auth/index.ts | 26 ++++++++++++------- .../src/services/oauth/oauth.service.ts | 17 +++++++----- 2 files changed, 28 insertions(+), 15 deletions(-) diff --git a/packages/backend/src/routes/auth/index.ts b/packages/backend/src/routes/auth/index.ts index cbcc9fb..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(); @@ -51,7 +59,7 @@ export const getAuthRouter = (serviceProvider: ServiceProvider) = }); // Build state parameter with all flow information - const stateData: any = {}; + const stateData: OAuthStateData = {}; if (deviceUserCode) stateData.device_code = deviceUserCode; if (desktop) { stateData.desktop = true; @@ -95,11 +103,11 @@ export const getAuthRouter = (serviceProvider: ServiceProvider) = }); // Parse state parameter for flow information - let stateData: any = {}; + let stateData: OAuthStateData = {}; try { const state = req.query.state as string; if (state) { - stateData = JSON.parse(state); + stateData = JSON.parse(state) as OAuthStateData; } } catch { // Invalid or missing state @@ -178,7 +186,7 @@ export const getAuthRouter = (serviceProvider: ServiceProvider) = }); // Build state parameter with desktop flow information - const stateData: any = {}; + const stateData: OAuthStateData = {}; if (desktop) { stateData.desktop = true; stateData.callback = callback; @@ -221,11 +229,11 @@ export const getAuthRouter = (serviceProvider: ServiceProvider) = }); // Parse state parameter for flow information - let stateData: any = {}; + let stateData: OAuthStateData = {}; try { const state = req.query.state as string; if (state) { - stateData = JSON.parse(state); + stateData = JSON.parse(state) as OAuthStateData; } } catch { // Invalid or missing state @@ -294,7 +302,7 @@ export const getAuthRouter = (serviceProvider: ServiceProvider) = }); // Build state parameter with desktop flow information - const stateData: any = {}; + const stateData: OAuthStateData = {}; if (desktop) { stateData.desktop = true; stateData.callback = callback; @@ -337,11 +345,11 @@ export const getAuthRouter = (serviceProvider: ServiceProvider) = }); // Parse state parameter for flow information - let stateData: any = {}; + let stateData: OAuthStateData = {}; try { const state = req.query.state as string; if (state) { - stateData = JSON.parse(state); + stateData = JSON.parse(state) as OAuthStateData; } } catch { // Invalid or missing state diff --git a/packages/backend/src/services/oauth/oauth.service.ts b/packages/backend/src/services/oauth/oauth.service.ts index f0c5ce0..86ede27 100644 --- a/packages/backend/src/services/oauth/oauth.service.ts +++ b/packages/backend/src/services/oauth/oauth.service.ts @@ -1,7 +1,7 @@ import passport from 'passport'; -import { Strategy as GitHubStrategy } from 'passport-github2'; -import { Strategy as GoogleStrategy } from 'passport-google-oauth20'; -import { Strategy as MicrosoftStrategy } from 'passport-microsoft'; +import { Profile as GitHubProfile, Strategy as GitHubStrategy } from 'passport-github2'; +import { Profile as GoogleProfile, Strategy as GoogleStrategy } from 'passport-google-oauth20'; +import { Profile as MicrosoftProfile, Strategy as MicrosoftStrategy } from 'passport-microsoft'; import type { ConfigService, LoggerService, Service } from '../app.services'; import type { UserService } from '../user'; @@ -42,7 +42,7 @@ export const createOAuthService = ( async ( accessToken: string, _refreshToken: string, - profile: any, + profile: GitHubProfile, done: (error: Error | null, user?: Express.User | false) => void ) => { try { @@ -108,7 +108,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) { @@ -179,7 +184,7 @@ export const createOAuthService = ( async ( accessToken: string, _refreshToken: string, - profile: any, + profile: MicrosoftProfile, done: (error: Error | null, user?: Express.User | false) => void ) => { try { From edb0f6bb3b9b3807d98f9ba2d05a84567fb89143 Mon Sep 17 00:00:00 2001 From: Volodymyr Vreshch Date: Fri, 12 Dec 2025 02:18:34 +0100 Subject: [PATCH 3/3] fix: add MicrosoftProfile interface for build compatibility passport-microsoft doesn't export Profile type, so define custom interface --- packages/backend/src/services/oauth/oauth.service.ts | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/backend/src/services/oauth/oauth.service.ts b/packages/backend/src/services/oauth/oauth.service.ts index 86ede27..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 { Profile as GitHubProfile, Strategy as GitHubStrategy } from 'passport-github2'; import { Profile as GoogleProfile, Strategy as GoogleStrategy } from 'passport-google-oauth20'; -import { Profile as MicrosoftProfile, Strategy as MicrosoftStrategy } from 'passport-microsoft'; +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