Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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
38 changes: 36 additions & 2 deletions docs/auth-setup.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -226,18 +250,22 @@ 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

```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
```

Expand Down Expand Up @@ -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,
Expand All @@ -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`

Expand Down
23 changes: 23 additions & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions packages/backend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand All @@ -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",
Expand Down
54 changes: 54 additions & 0 deletions packages/backend/src/routes/auth/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,60 @@ export const getAuthRouter = (serviceProvider: ServiceProvider<AppServiceMap>) =
}
);

// ===================================================================
// 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)
// ===================================================================
Expand Down
5 changes: 3 additions & 2 deletions packages/backend/src/services/app.services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,8 +87,9 @@ export class ServiceProvider<T extends Record<string, Service>> {
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;
Expand Down
76 changes: 76 additions & 0 deletions packages/backend/src/services/oauth/oauth.service.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand Down Expand Up @@ -41,7 +42,7 @@
async (
_accessToken: string,
_refreshToken: string,
profile: any,

Check warning on line 45 in packages/backend/src/services/oauth/oauth.service.ts

View workflow job for this annotation

GitHub Actions / 🔍 Validate Pull Request

Unexpected any. Specify a different type
done: (error: Error | null, user?: Express.User | false) => void
) => {
try {
Expand Down Expand Up @@ -149,6 +150,81 @@
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,

Check warning on line 177 in packages/backend/src/services/oauth/oauth.service.ts

View workflow job for this annotation

GitHub Actions / 🔍 Validate Pull Request

Unexpected any. Specify a different type
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);
Expand Down
55 changes: 55 additions & 0 deletions packages/frontend/src/app/auth/callback/page.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="text-center">
<div className="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mb-4"></div>
<p className="text-gray-600 text-lg">Authenticating...</p>
</div>
</div>
);
}

export default function AuthCallbackPage() {
return (
<Suspense
fallback={
<div className="min-h-screen flex items-center justify-center bg-gray-50">
<div className="text-center">
<div className="inline-block animate-spin rounded-full h-12 w-12 border-b-2 border-blue-600 mb-4"></div>
<p className="text-gray-600 text-lg">Loading...</p>
</div>
</div>
}
>
<AuthCallbackContent />
</Suspense>
);
}
Loading