Skip to content

Commit 9078374

Browse files
pktikkaniclaude
andcommitted
Add Microsoft OAuth flow and audit sync
- Add crypto utilities for token encryption - Add TokenManager for OAuth token handling - Add Microsoft connect/callback endpoints for OAuth flow - Add Management API client for fetching audit events - Add sync endpoints for pulling SharePoint audit logs New endpoints: - GET /api/microsoft/connect - Start OAuth flow - GET /api/microsoft/callback - Handle OAuth callback - GET /api/microsoft/status/{userId} - Get connection status - POST /api/sync/events - Sync audit events - POST /api/sync/subscription/start - Start audit subscription - GET /api/sync/subscriptions - List subscriptions 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1 parent 1322a2f commit 9078374

6 files changed

Lines changed: 1138 additions & 0 deletions

File tree

deploy.zip

94.9 KB
Binary file not shown.

src/functions/microsoft.ts

Lines changed: 301 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,301 @@
1+
import { app, HttpRequest, HttpResponseInit, InvocationContext } from '@azure/functions';
2+
import crypto from 'crypto';
3+
import { db } from '../lib/db/prisma.js';
4+
import { getAuthorizationUrl, TokenManager, getAppCredentials } from '../lib/microsoft/token-manager.js';
5+
6+
// In-memory state storage (in production, use Redis or similar)
7+
const stateStore: Map<string, { userId: string; createdAt: number }> = new Map();
8+
9+
// Clean up expired states (older than 10 minutes)
10+
function cleanupStates() {
11+
const now = Date.now();
12+
for (const [state, data] of stateStore.entries()) {
13+
if (now - data.createdAt > 10 * 60 * 1000) {
14+
stateStore.delete(state);
15+
}
16+
}
17+
}
18+
19+
/**
20+
* Microsoft Connect - Initiates OAuth flow
21+
* GET /api/microsoft/connect?userId=xxx&returnUrl=xxx
22+
*/
23+
app.http('microsoft-connect', {
24+
methods: ['GET'],
25+
authLevel: 'anonymous',
26+
route: 'microsoft/connect',
27+
handler: async (request: HttpRequest, context: InvocationContext): Promise<HttpResponseInit> => {
28+
try {
29+
const url = new URL(request.url);
30+
const userId = url.searchParams.get('userId');
31+
const returnUrl = url.searchParams.get('returnUrl') || process.env.SPFX_RETURN_URL || 'https://localhost:4321';
32+
33+
if (!userId) {
34+
return {
35+
status: 400,
36+
jsonBody: { error: 'userId is required' },
37+
};
38+
}
39+
40+
// Verify user exists
41+
const user = await db.user.findUnique({
42+
where: { id: userId },
43+
});
44+
45+
if (!user) {
46+
return {
47+
status: 404,
48+
jsonBody: { error: 'User not found' },
49+
};
50+
}
51+
52+
// Generate state for CSRF protection
53+
const state = crypto.randomBytes(32).toString('hex');
54+
55+
// Store state with userId
56+
cleanupStates();
57+
stateStore.set(state, { userId, createdAt: Date.now() });
58+
59+
// Build redirect URI
60+
const baseUrl = process.env.API_BASE_URL || `https://${request.headers.get('host')}`;
61+
const redirectUri = `${baseUrl}/api/microsoft/callback`;
62+
63+
// Get authorization URL
64+
const authUrl = await getAuthorizationUrl(state, redirectUri, userId);
65+
66+
// Store return URL in state (append to state)
67+
const fullState = `${state}|${Buffer.from(returnUrl).toString('base64')}`;
68+
stateStore.set(fullState, { userId, createdAt: Date.now() });
69+
70+
// Update auth URL with full state
71+
const authUrlWithReturn = authUrl.replace(`state=${state}`, `state=${encodeURIComponent(fullState)}`);
72+
73+
return {
74+
status: 302,
75+
headers: {
76+
'Location': authUrlWithReturn,
77+
},
78+
};
79+
} catch (error) {
80+
context.error('Microsoft connect error:', error);
81+
82+
if (error instanceof Error && error.message.includes('credentials not configured')) {
83+
return {
84+
status: 400,
85+
jsonBody: { error: 'Microsoft credentials not configured. Please set up credentials first.' },
86+
};
87+
}
88+
89+
return {
90+
status: 500,
91+
jsonBody: { error: 'Failed to initiate Microsoft connection' },
92+
};
93+
}
94+
},
95+
});
96+
97+
/**
98+
* Microsoft Callback - Handles OAuth callback
99+
* GET /api/microsoft/callback?code=xxx&state=xxx
100+
*/
101+
app.http('microsoft-callback', {
102+
methods: ['GET'],
103+
authLevel: 'anonymous',
104+
route: 'microsoft/callback',
105+
handler: async (request: HttpRequest, context: InvocationContext): Promise<HttpResponseInit> => {
106+
try {
107+
const url = new URL(request.url);
108+
const code = url.searchParams.get('code');
109+
const state = url.searchParams.get('state');
110+
const error = url.searchParams.get('error');
111+
const errorDescription = url.searchParams.get('error_description');
112+
113+
// Default return URL
114+
let returnUrl = process.env.SPFX_RETURN_URL || 'https://localhost:4321';
115+
116+
// Parse state to extract return URL
117+
if (state) {
118+
const stateParts = decodeURIComponent(state).split('|');
119+
if (stateParts.length > 1) {
120+
try {
121+
returnUrl = Buffer.from(stateParts[1], 'base64').toString('utf8');
122+
} catch {
123+
// Ignore invalid base64
124+
}
125+
}
126+
}
127+
128+
// Handle OAuth errors
129+
if (error) {
130+
context.error('OAuth error:', error, errorDescription);
131+
return {
132+
status: 302,
133+
headers: {
134+
'Location': `${returnUrl}?error=${encodeURIComponent(errorDescription || error)}`,
135+
},
136+
};
137+
}
138+
139+
if (!code || !state) {
140+
return {
141+
status: 302,
142+
headers: {
143+
'Location': `${returnUrl}?error=missing_params`,
144+
},
145+
};
146+
}
147+
148+
// Validate state
149+
const stateData = stateStore.get(decodeURIComponent(state));
150+
if (!stateData) {
151+
return {
152+
status: 302,
153+
headers: {
154+
'Location': `${returnUrl}?error=invalid_state`,
155+
},
156+
};
157+
}
158+
159+
const { userId } = stateData;
160+
stateStore.delete(decodeURIComponent(state));
161+
162+
// Get user
163+
const user = await db.user.findUnique({
164+
where: { id: userId },
165+
});
166+
167+
if (!user) {
168+
return {
169+
status: 302,
170+
headers: {
171+
'Location': `${returnUrl}?error=user_not_found`,
172+
},
173+
};
174+
}
175+
176+
// Get credentials
177+
const credentials = await getAppCredentials(userId);
178+
const tenantId = credentials.tenantId;
179+
180+
// Build redirect URI (must match what was used in connect)
181+
const baseUrl = process.env.API_BASE_URL || `https://${request.headers.get('host')}`;
182+
const redirectUri = `${baseUrl}/api/microsoft/callback`;
183+
184+
// Exchange code for tokens
185+
const tokens = await TokenManager.exchangeCodeForTokens(
186+
code,
187+
tenantId,
188+
redirectUri,
189+
userId
190+
);
191+
192+
// Get tenant info from Graph API
193+
let tenantName: string | null = null;
194+
try {
195+
const orgResponse = await fetch(
196+
'https://graph.microsoft.com/v1.0/organization',
197+
{
198+
headers: {
199+
Authorization: `Bearer ${tokens.access_token}`,
200+
},
201+
}
202+
);
203+
if (orgResponse.ok) {
204+
const orgData = await orgResponse.json() as { value?: Array<{ displayName?: string }> };
205+
tenantName = orgData.value?.[0]?.displayName || null;
206+
}
207+
} catch (e) {
208+
context.warn('Failed to get tenant info:', e);
209+
}
210+
211+
// Store tokens
212+
const scopes = [
213+
'Sites.Read.All',
214+
'AuditLog.Read.All',
215+
'SecurityEvents.Read.All',
216+
'Directory.Read.All',
217+
'User.Read.All',
218+
'ActivityFeed.Read',
219+
];
220+
221+
await TokenManager.storeTokens(
222+
userId,
223+
tenantId,
224+
tenantName,
225+
tokens,
226+
scopes
227+
);
228+
229+
context.log(`Microsoft 365 connected for user ${userId}, tenant: ${tenantName || tenantId}`);
230+
231+
return {
232+
status: 302,
233+
headers: {
234+
'Location': `${returnUrl}?success=true&tenant=${encodeURIComponent(tenantName || tenantId)}`,
235+
},
236+
};
237+
} catch (error) {
238+
context.error('Microsoft callback error:', error);
239+
240+
const returnUrl = process.env.SPFX_RETURN_URL || 'https://localhost:4321';
241+
return {
242+
status: 302,
243+
headers: {
244+
'Location': `${returnUrl}?error=${encodeURIComponent(String(error))}`,
245+
},
246+
};
247+
}
248+
},
249+
});
250+
251+
/**
252+
* Microsoft Status - Get connection status for a user
253+
* GET /api/microsoft/status (requires auth via tRPC, but also available directly)
254+
*/
255+
app.http('microsoft-status', {
256+
methods: ['GET'],
257+
authLevel: 'anonymous',
258+
route: 'microsoft/status/{userId}',
259+
handler: async (request: HttpRequest, context: InvocationContext): Promise<HttpResponseInit> => {
260+
try {
261+
const userId = request.params.userId;
262+
263+
if (!userId) {
264+
return {
265+
status: 400,
266+
jsonBody: { error: 'userId is required' },
267+
};
268+
}
269+
270+
const connections = await db.microsoftConnection.findMany({
271+
where: {
272+
userId,
273+
isActive: true,
274+
},
275+
select: {
276+
id: true,
277+
tenantId: true,
278+
tenantName: true,
279+
status: true,
280+
isActive: true,
281+
lastSyncAt: true,
282+
tokenExpiresAt: true,
283+
createdAt: true,
284+
},
285+
});
286+
287+
return {
288+
jsonBody: {
289+
connected: connections.length > 0,
290+
connections,
291+
},
292+
};
293+
} catch (error) {
294+
context.error('Microsoft status error:', error);
295+
return {
296+
status: 500,
297+
jsonBody: { error: 'Failed to get Microsoft status' },
298+
};
299+
}
300+
},
301+
});

0 commit comments

Comments
 (0)