diff --git a/.env.example b/.env.example index 5b420f9..44a078a 100644 --- a/.env.example +++ b/.env.example @@ -32,3 +32,9 @@ GOOGLE_CLIENT_ID=your-google-client-id GOOGLE_CLIENT_SECRET=your-google-client-secret GOOGLE_CALLBACK_URL=http://localhost:3001/api/auth/google/callback +# =================================================================== +# Microsoft OAuth Configuration (Optional) +# =================================================================== +MICROSOFT_CLIENT_ID=your-microsoft-client-id +MICROSOFT_CLIENT_SECRET=your-microsoft-client-secret +MICROSOFT_CALLBACK_URL=http://localhost:3001/api/auth/microsoft/callback diff --git a/docs/auth-setup.md b/docs/auth-setup.md index c16b52d..86bb380 100644 --- a/docs/auth-setup.md +++ b/docs/auth-setup.md @@ -86,7 +86,29 @@ GOOGLE_CLIENT_SECRET=your-google-client-secret-here GOOGLE_CALLBACK_URL=http://localhost:3001/api/auth/google/callback ``` -### 5. Build and Start the Server +### 5. Set Up Microsoft OAuth (Optional) + +1. Go to https://portal.azure.com +2. Navigate to "Azure Active Directory" → "App registrations" +3. Click "New registration" +4. Fill in the details: + - **Name**: Agentage (Development) + - **Supported account types**: Select "Accounts in any organizational directory and personal Microsoft accounts" + - **Redirect URI**: Select "Web" and enter `http://localhost:3001/api/auth/microsoft/callback` +5. Click "Register" +6. Copy the **Application (client) ID** +7. Go to "Certificates & secrets" → "New client secret" +8. Add a description and expiration period, then click "Add" +9. Copy the **Client Secret** value (you won't be able to see it again) +10. Add to `.env`: + +```bash +MICROSOFT_CLIENT_ID=your-microsoft-client-id-here +MICROSOFT_CLIENT_SECRET=your-microsoft-client-secret-here +MICROSOFT_CALLBACK_URL=http://localhost:3001/api/auth/microsoft/callback +``` + +### 6. Build and Start the Server ```bash # Build shared package @@ -106,6 +128,8 @@ npm run dev - `GET /api/auth/github/callback` - GitHub OAuth callback - `GET /api/auth/google` - Initiate Google OAuth login - `GET /api/auth/google/callback` - Google OAuth callback +- `GET /api/auth/microsoft` - Initiate Microsoft OAuth login +- `GET /api/auth/microsoft/callback` - Microsoft OAuth callback - `POST /api/auth/logout` - Logout (client-side token removal) ### Protected Endpoints (Require JWT Token) @@ -226,7 +250,7 @@ The JWT token contains: 1. **Use HTTPS**: Always use HTTPS in production 2. **Strong JWT Secret**: Use a random 32+ character secret 3. **Update Callback URLs**: Update OAuth callback URLs to production domain -4. **Rotate Secrets**: Periodically rotate JWT secret +4. **Rotate Secrets**: Periodically rotate JWT secret and OAuth client secrets 5. **Monitor Failed Attempts**: Log and monitor failed authentication attempts ### Environment-Specific Configurations @@ -234,10 +258,14 @@ The JWT token contains: ```bash # Development GITHUB_CALLBACK_URL=http://localhost:3001/api/auth/github/callback +GOOGLE_CALLBACK_URL=http://localhost:3001/api/auth/google/callback +MICROSOFT_CALLBACK_URL=http://localhost:3001/api/auth/microsoft/callback FRONTEND_FQDN=localhost:3000 # Production GITHUB_CALLBACK_URL=https://api.agentage.io/api/auth/github/callback +GOOGLE_CALLBACK_URL=https://api.agentage.io/api/auth/google/callback +MICROSOFT_CALLBACK_URL=https://api.agentage.io/api/auth/microsoft/callback FRONTEND_FQDN=agentage.io ``` @@ -293,6 +321,11 @@ The authentication system creates a `users` collection with this structure: id: "67890", email: "user@example.com", connectedAt: Date + }, + microsoft: { + id: "abcdef", + email: "user@example.com", + connectedAt: Date } }, createdAt: Date, @@ -307,6 +340,7 @@ The following indexes are automatically created: - `email` (unique) - `providers.github.id` (unique, sparse) - `providers.google.id` (unique, sparse) +- `providers.microsoft.id` (unique, sparse) - `role` - `isActive` diff --git a/package-lock.json b/package-lock.json index 8b51929..f37c1e2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2024,6 +2024,16 @@ "@types/passport-oauth2": "*" } }, + "node_modules/@types/passport-microsoft": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/@types/passport-microsoft/-/passport-microsoft-2.1.1.tgz", + "integrity": "sha512-mtxO0nUt8PSAQd1MPD4JZXacYRx9MXYCTFjKyBEarmCJYyJlKHMwBRYltBEi/zpvC6xYX0LgK1AFXp+hQI+DEw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/passport-oauth2": "*" + } + }, "node_modules/@types/passport-oauth2": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/@types/passport-oauth2/-/passport-oauth2-1.8.0.tgz", @@ -8256,6 +8266,17 @@ "node": ">= 0.4.0" } }, + "node_modules/passport-microsoft": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/passport-microsoft/-/passport-microsoft-2.1.0.tgz", + "integrity": "sha512-7bOcjEmZCHg5qD55iHaMD/mgBxPtXLbqAwmKox5IsqOSEU50WJk5nQKK4lxKdBHLZ0hf+gzrFgDsTybJP18/JA==", + "dependencies": { + "passport-oauth2": "1.8.0" + }, + "engines": { + "node": ">= 0.4.0" + } + }, "node_modules/passport-oauth2": { "version": "1.8.0", "resolved": "https://registry.npmjs.org/passport-oauth2/-/passport-oauth2-1.8.0.tgz", @@ -10794,6 +10815,7 @@ "passport": "^0.7.0", "passport-github2": "^0.1.12", "passport-google-oauth20": "^2.0.0", + "passport-microsoft": "^2.1.0", "semver": "^7.7.3", "zod": "^3.25.67" }, @@ -10807,6 +10829,7 @@ "@types/passport": "^1.0.17", "@types/passport-github2": "^1.2.9", "@types/passport-google-oauth20": "^2.0.0", + "@types/passport-microsoft": "^2.1.1", "@types/semver": "^7.7.1", "@typescript-eslint/eslint-plugin": "^8.39.0", "@typescript-eslint/parser": "^8.39.0", diff --git a/packages/backend/package.json b/packages/backend/package.json index 94a7c87..ca39e87 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -26,6 +26,7 @@ "passport": "^0.7.0", "passport-github2": "^0.1.12", "passport-google-oauth20": "^2.0.0", + "passport-microsoft": "^2.1.0", "semver": "^7.7.3", "zod": "^3.25.67" }, @@ -39,6 +40,7 @@ "@types/passport": "^1.0.17", "@types/passport-github2": "^1.2.9", "@types/passport-google-oauth20": "^2.0.0", + "@types/passport-microsoft": "^2.1.1", "@types/semver": "^7.7.1", "@typescript-eslint/eslint-plugin": "^8.39.0", "@typescript-eslint/parser": "^8.39.0", diff --git a/packages/backend/src/routes/auth/index.ts b/packages/backend/src/routes/auth/index.ts index d7b6929..e63f472 100644 --- a/packages/backend/src/routes/auth/index.ts +++ b/packages/backend/src/routes/auth/index.ts @@ -133,6 +133,60 @@ export const getAuthRouter = (serviceProvider: ServiceProvider) = } ); + // =================================================================== + // Microsoft OAuth Routes + // =================================================================== + + router.get('/microsoft', async (req: Request, res: Response, next) => { + try { + const logger = await serviceProvider.get('logger'); + logger.info('Microsoft OAuth initiated', { + ip: req.ip, + userAgent: req.get('User-Agent'), + }); + + passport.authenticate('microsoft', { scope: ['user.read'] })(req, res, next); + } catch (error) { + next(error); + } + }); + + router.get( + '/microsoft/callback', + passport.authenticate('microsoft', { session: false, failureRedirect: '/login' }), + async (req: Request, res: Response, next) => { + try { + const logger = await serviceProvider.get('logger'); + const jwtService = await serviceProvider.get('jwt'); + const config = await serviceProvider.get('config'); + + if (!req.user) { + return res.status(401).json({ error: 'Authentication failed' }); + } + + // Generate JWT token + const token = jwtService.generateToken({ + userId: req.user.id || req.user._id?.toString() || '', + email: req.user.email, + role: req.user.role, + }); + + logger.info('Microsoft OAuth callback successful - JWT generated', { + userId: req.user.id, + email: req.user.email, + }); + + // Redirect to frontend with token + const frontendFqdn = config.get('FRONTEND_FQDN', 'localhost:3000'); + const protocol = process.env.NODE_ENV === 'production' ? 'https' : 'http'; + const redirectUrl = `${protocol}://${frontendFqdn}/auth/callback?token=${token}`; + res.redirect(redirectUrl); + } catch (error) { + next(error); + } + } + ); + // =================================================================== // User Info & Management Routes (JWT-protected) // =================================================================== diff --git a/packages/backend/src/services/app.services.ts b/packages/backend/src/services/app.services.ts index 8b3a128..32be284 100644 --- a/packages/backend/src/services/app.services.ts +++ b/packages/backend/src/services/app.services.ts @@ -87,8 +87,9 @@ export class ServiceProvider> { function createConfigService(): ConfigService { return { async initialize() { - // Load .env file - require('dotenv').config(); + // Load .env file from workspace root + const path = require('path'); + require('dotenv').config({ path: path.resolve(__dirname, '../../../../.env') }); }, get(key: string, defaultValue = ''): string { return process.env[key] || defaultValue; diff --git a/packages/backend/src/services/oauth/oauth.service.ts b/packages/backend/src/services/oauth/oauth.service.ts index a64f7f9..9a828af 100644 --- a/packages/backend/src/services/oauth/oauth.service.ts +++ b/packages/backend/src/services/oauth/oauth.service.ts @@ -1,6 +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 type { ConfigService, LoggerService, Service } from '../app.services'; import type { UserService } from '../user'; @@ -149,6 +150,81 @@ export const createOAuthService = ( logger.warn('Google OAuth not configured - missing credentials'); } + // =================================================================== + // Microsoft OAuth Strategy + // =================================================================== + const microsoftClientId = config.get('MICROSOFT_CLIENT_ID'); + const microsoftClientSecret = config.get('MICROSOFT_CLIENT_SECRET'); + const microsoftCallbackUrl = config.get('MICROSOFT_CALLBACK_URL'); + + if (microsoftClientId && microsoftClientSecret && microsoftCallbackUrl) { + logger.info('Configuring Microsoft OAuth strategy...', { + clientId: microsoftClientId.substring(0, 8) + '...', + callbackUrl: microsoftCallbackUrl, + }); + + passport.use( + new MicrosoftStrategy( + { + clientID: microsoftClientId, + clientSecret: microsoftClientSecret, + callbackURL: microsoftCallbackUrl, + scope: ['user.read'], + }, + async ( + _accessToken: string, + _refreshToken: string, + profile: any, + done: (error: Error | null, user?: Express.User | false) => void + ) => { + try { + const email = profile.emails?.[0]?.value; + if (!email) { + return done(new Error('No email provided by Microsoft')); + } + + const userDoc = await user.findOrCreateUser({ + provider: 'microsoft', + providerId: profile.id, + email, + name: profile.displayName || email, + avatar: profile.photos?.[0]?.value, + }); + + const expressUser: Express.User = { + id: userDoc._id!, + _id: userDoc._id!, + email: userDoc.email, + displayName: userDoc.name, + avatar: userDoc.avatar, + providers: userDoc.providers, + role: userDoc.role, + createdAt: userDoc.createdAt, + updatedAt: userDoc.updatedAt, + }; + + logger.info('Microsoft OAuth successful', { + userId: userDoc._id, + email: userDoc.email, + }); + + done(null, expressUser); + } catch (error) { + logger.error('Microsoft OAuth error', { error }); + done(error as Error); + } + } + ) + ); + logger.info('Microsoft OAuth strategy configured'); + } else { + logger.warn('Microsoft OAuth not configured - missing credentials', { + hasClientId: !!microsoftClientId, + hasClientSecret: !!microsoftClientSecret, + hasCallbackUrl: !!microsoftCallbackUrl, + }); + } + // Passport serialization (not used in stateless JWT, but required by passport) passport.serializeUser((user: Express.User, done) => { done(null, user.id || user.userId); diff --git a/packages/frontend/src/app/auth/callback/page.tsx b/packages/frontend/src/app/auth/callback/page.tsx new file mode 100644 index 0000000..1b0d865 --- /dev/null +++ b/packages/frontend/src/app/auth/callback/page.tsx @@ -0,0 +1,55 @@ +'use client'; + +import { useRouter, useSearchParams } from 'next/navigation'; +import { Suspense, useEffect } from 'react'; +import { authApi } from '../../../lib/auth-api'; + +function AuthCallbackContent() { + const router = useRouter(); + const searchParams = useSearchParams(); + + useEffect(() => { + // Extract token from URL query parameter + const token = searchParams.get('token'); + + if (token) { + // Store the JWT token + authApi.storeToken(token); + + // Trigger storage event to notify other components + window.dispatchEvent(new Event('auth-token-changed')); + + // Redirect to home page + router.push('/'); + } else { + // No token received - redirect to home with error + router.push('/?error=auth_failed'); + } + }, [searchParams, router]); + + return ( +
+
+
+

Authenticating...

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

Loading...

+
+ + } + > + +
+ ); +} diff --git a/packages/frontend/src/app/contact/ContactForm.tsx b/packages/frontend/src/app/contact/ContactForm.tsx index 2a7ab98..348d556 100644 --- a/packages/frontend/src/app/contact/ContactForm.tsx +++ b/packages/frontend/src/app/contact/ContactForm.tsx @@ -1,5 +1,6 @@ 'use client'; +import Link from 'next/link'; import { useState } from 'react'; export function ContactForm() { @@ -118,13 +119,13 @@ export function ContactForm() { /> diff --git a/packages/frontend/src/components/auth/LoginButton.tsx b/packages/frontend/src/components/auth/LoginButton.tsx new file mode 100644 index 0000000..9fdc56e --- /dev/null +++ b/packages/frontend/src/components/auth/LoginButton.tsx @@ -0,0 +1,66 @@ +'use client'; + +interface LoginButtonProps { + provider: 'google' | 'github' | 'microsoft'; + onClick: () => void; + className?: string; +} + +const providerConfig = { + google: { + name: 'Google', + icon: ( + + + + + + + ), + color: 'bg-white hover:bg-gray-50 text-gray-900 border border-gray-300', + }, + github: { + name: 'GitHub', + icon: ( + + + + ), + color: 'bg-gray-900 hover:bg-gray-800 text-white', + }, + microsoft: { + name: 'Microsoft', + icon: ( + + + + ), + color: 'bg-blue-600 hover:bg-blue-700 text-white', + }, +}; + +export const LoginButton = ({ provider, onClick, className = '' }: LoginButtonProps) => { + const config = providerConfig[provider]; + + return ( + + ); +}; diff --git a/packages/frontend/src/components/auth/LoginModal.tsx b/packages/frontend/src/components/auth/LoginModal.tsx new file mode 100644 index 0000000..2d3dbc3 --- /dev/null +++ b/packages/frontend/src/components/auth/LoginModal.tsx @@ -0,0 +1,75 @@ +'use client'; + +import Link from 'next/link'; +import { LoginButton } from './LoginButton'; + +interface LoginModalProps { + isOpen: boolean; + onClose: () => void; + onLogin: (provider: 'google' | 'github' | 'microsoft') => void; +} + +export const LoginModal = ({ isOpen, onClose, onLogin }: LoginModalProps) => { + if (!isOpen) return null; + + return ( + <> + {/* Backdrop */} + @@ -68,14 +68,14 @@ export const Footer = () => {

Company