/**
* Microsoft Entra (Azure AD) Identity Provider
*
* Implements Layer 1 authentication using Microsoft OAuth 2.0.
* Provides user identity (email, name, picture) for MCP client authentication.
*
* Supports:
* - Personal Microsoft accounts (consumers)
* - Work/school accounts (organizations)
* - Multi-tenant apps (common)
*
* Usage:
* import { buildMicrosoftAuthUrl, fetchMicrosoftUserInfo, ... } from '../auth/identity/microsoft';
*/
import type { IdentityTokens, UserIdentity } from './types';
import { MICROSOFT_CONFIG } from './types';
// =============================================================================
// Configuration
// =============================================================================
/**
* Default scopes for identity + basic Graph API access
* - openid, email, profile: Standard OIDC claims
* - User.Read: Read user's basic profile from Graph API
* - offline_access: Get refresh tokens
*/
export const MICROSOFT_IDENTITY_SCOPES = 'openid email profile User.Read offline_access';
/**
* Build tenant-specific endpoint URL
*/
function getTenantEndpoint(baseUrl: string, tenant: string = 'common'): string {
return baseUrl.replace('/common/', `/${tenant}/`);
}
// =============================================================================
// Authorization URL
// =============================================================================
/**
* Constructs a Microsoft OAuth authorization URL
*
* @param params - OAuth parameters
* @returns Full authorization URL
*/
export function buildMicrosoftAuthUrl(params: {
clientId: string;
redirectUri: string;
scope: string;
state: string;
tenant?: string; // 'common' | 'organizations' | 'consumers' | tenant ID
prompt?: 'login' | 'consent' | 'select_account' | 'none';
}): string {
const tenant = params.tenant ?? 'common';
const endpoint = getTenantEndpoint(MICROSOFT_CONFIG.authorizationEndpoint, tenant);
const url = new URL(endpoint);
url.searchParams.set('client_id', params.clientId);
url.searchParams.set('redirect_uri', params.redirectUri);
url.searchParams.set('scope', params.scope);
url.searchParams.set('state', params.state);
url.searchParams.set('response_type', 'code');
url.searchParams.set('response_mode', 'query');
// Force consent on first auth to ensure we get refresh token
if (params.prompt) {
url.searchParams.set('prompt', params.prompt);
} else if (params.scope.includes('offline_access')) {
url.searchParams.set('prompt', 'consent');
}
return url.href;
}
// =============================================================================
// Token Exchange
// =============================================================================
/**
* Exchange authorization code for tokens
*
* @param params - Token exchange parameters
* @returns Tokens or error response
*/
export async function exchangeMicrosoftCode(params: {
clientId: string;
clientSecret: string;
code: string;
redirectUri: string;
tenant?: string;
}): Promise<[IdentityTokens, null] | [null, Response]> {
const tenant = params.tenant ?? 'common';
const endpoint = getTenantEndpoint(MICROSOFT_CONFIG.tokenEndpoint, tenant);
const resp = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_id: params.clientId,
client_secret: params.clientSecret,
code: params.code,
redirect_uri: params.redirectUri,
grant_type: 'authorization_code',
}).toString(),
});
if (!resp.ok) {
const errorText = await resp.text();
console.error('Microsoft token exchange failed:', errorText);
return [null, new Response('Failed to fetch access token', { status: 500 })];
}
const body = (await resp.json()) as {
access_token?: string;
refresh_token?: string;
expires_in?: number;
id_token?: string;
scope?: string;
error?: string;
error_description?: string;
};
if (body.error) {
console.error('Microsoft OAuth error:', body.error, body.error_description);
return [null, new Response(`OAuth error: ${body.error}`, { status: 400 })];
}
if (!body.access_token) {
return [null, new Response('Missing access token', { status: 400 })];
}
if (body.refresh_token) {
console.log('OAuth: Microsoft refresh token obtained');
} else {
console.warn('OAuth: No Microsoft refresh token received (ensure offline_access scope)');
}
return [
{
accessToken: body.access_token,
refreshToken: body.refresh_token,
expiresAt: Date.now() + (body.expires_in ?? 3600) * 1000,
idToken: body.id_token,
scope: body.scope,
},
null,
];
}
// =============================================================================
// Token Refresh
// =============================================================================
/**
* Refresh an access token using a refresh token
*
* @param params - Refresh parameters
* @returns New tokens or null on error
*/
export async function refreshMicrosoftToken(params: {
clientId: string;
clientSecret: string;
refreshToken: string;
tenant?: string;
}): Promise<IdentityTokens | null> {
const tenant = params.tenant ?? 'common';
const endpoint = getTenantEndpoint(MICROSOFT_CONFIG.tokenEndpoint, tenant);
const resp = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_id: params.clientId,
client_secret: params.clientSecret,
refresh_token: params.refreshToken,
grant_type: 'refresh_token',
}).toString(),
});
if (!resp.ok) {
console.error('Microsoft token refresh failed:', await resp.text());
return null;
}
const body = (await resp.json()) as {
access_token?: string;
refresh_token?: string;
expires_in?: number;
id_token?: string;
};
if (!body.access_token) {
console.error('Microsoft token refresh: no access_token in response');
return null;
}
return {
accessToken: body.access_token,
refreshToken: body.refresh_token || params.refreshToken,
expiresAt: Date.now() + (body.expires_in ?? 3600) * 1000,
idToken: body.id_token,
};
}
// =============================================================================
// User Info
// =============================================================================
/**
* Fetch user info from Microsoft Graph API
*
* Microsoft Graph returns different email fields depending on account type:
* - Work accounts: 'mail' field
* - Personal accounts: 'userPrincipalName' (which is the email)
*
* @param accessToken - Valid access token
* @returns Normalized user identity or null on error
*/
export async function fetchMicrosoftUserInfo(
accessToken: string
): Promise<UserIdentity | null> {
console.log('Fetching Microsoft user info from:', MICROSOFT_CONFIG.userInfoEndpoint);
const resp = await fetch(MICROSOFT_CONFIG.userInfoEndpoint, {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
if (!resp.ok) {
const errorText = await resp.text();
console.error('Failed to fetch Microsoft user info:', resp.status, errorText);
return null;
}
const data = (await resp.json()) as {
id: string;
displayName?: string;
givenName?: string;
surname?: string;
mail?: string; // Work/school accounts
userPrincipalName?: string; // Personal accounts (email format)
jobTitle?: string;
officeLocation?: string;
};
// Microsoft may return email in 'mail' or 'userPrincipalName'
const email = data.mail || data.userPrincipalName || '';
if (!email) {
console.error('Microsoft user info: no email found');
return null;
}
return {
id: data.id,
email,
name: data.displayName || `${data.givenName || ''} ${data.surname || ''}`.trim() || undefined,
picture: undefined, // Microsoft Graph doesn't return picture URL directly
provider: 'microsoft',
emailVerified: true, // Microsoft verifies emails
raw: data,
};
}
// =============================================================================
// Profile Photo (Optional)
// =============================================================================
/**
* Fetch user's profile photo from Microsoft Graph
* Returns a data URL or undefined if no photo
*
* Note: This requires an additional API call and may not be needed for all use cases
*/
export async function fetchMicrosoftProfilePhoto(
accessToken: string
): Promise<string | undefined> {
try {
const resp = await fetch('https://graph.microsoft.com/v1.0/me/photo/$value', {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
if (!resp.ok) {
return undefined; // No photo or insufficient permissions
}
const blob = await resp.blob();
const buffer = await blob.arrayBuffer();
const base64 = btoa(String.fromCharCode(...new Uint8Array(buffer)));
return `data:${blob.type};base64,${base64}`;
} catch {
return undefined;
}
}
// =============================================================================
// Re-export config
// =============================================================================
export { MICROSOFT_CONFIG };