Files
Job-Info-Prod/Mode6Test/backend/auth-routes.ts
T

297 lines
8.2 KiB
TypeScript
Executable File

/**
* ROUTES: OAuth Authentication
*
* PURPOSE: Handle OAuth2 authorization and token exchange
* ENDPOINTS:
* - POST /api/auth/authorize - Exchange authorization code for token
* - POST /api/auth/refresh - Refresh access token using refresh token
* - POST /api/auth/logout - Logout and invalidate tokens
*
* SECURITY:
* - Client secret kept server-side only
* - Refresh tokens stored in HttpOnly cookies
* - PKCE verification with code verifier
* - CORS protection
*/
import { Context } from 'hono';
// ============================================================================
// TYPES
// ============================================================================
interface AuthorizeRequest {
code: string;
state: string;
codeVerifier: string;
}
interface TokenResponse {
accessToken: string;
refreshToken: string;
expiresIn: number;
user: {
id: string;
displayName: string;
mail: string;
};
}
interface RefreshTokenRequest {
refreshToken: string;
}
// ============================================================================
// CONFIGURATION
// ============================================================================
const MICROSOFT_TENANT_ID = process.env.MICROSOFT_TENANT_ID || 'common';
const MICROSOFT_CLIENT_ID = process.env.MICROSOFT_CLIENT_ID || '';
const MICROSOFT_CLIENT_SECRET = process.env.MICROSOFT_CLIENT_SECRET || '';
const REDIRECT_URI = process.env.MICROSOFT_REDIRECT_URI || '';
// Token endpoints
const TOKEN_ENDPOINT = `https://login.microsoftonline.com/${MICROSOFT_TENANT_ID}/oauth2/v2.0/token`;
const GRAPH_ENDPOINT = 'https://graph.microsoft.com/v1.0/me';
// In-memory token store (in production, use Redis or database)
const tokenStore = new Map<string, { refreshToken: string; expiresAt: number }>();
// ============================================================================
// AUTHORIZATION ENDPOINT
// ============================================================================
/**
* POST /api/auth/authorize
*
* Exchange authorization code for access token
*
* REQUEST:
* {
* code: string (from Microsoft OAuth redirect)
* state: string (CSRF token)
* codeVerifier: string (PKCE code verifier)
* }
*
* RESPONSE:
* {
* accessToken: string
* refreshToken: string
* expiresIn: number (seconds)
* user: { id, displayName, mail }
* }
*/
export async function handleAuthorize(c: Context): Promise<Response> {
try {
const body = await c.req.json() as AuthorizeRequest;
const { code, state, codeVerifier } = body;
// Validate inputs
if (!code || !state || !codeVerifier) {
return c.json(
{ error: 'Missing required parameters', details: 'code, state, codeVerifier required' },
{ status: 400 }
);
}
// Validate configuration
if (!MICROSOFT_CLIENT_ID || !MICROSOFT_CLIENT_SECRET || !REDIRECT_URI) {
console.error('[Auth] Missing OAuth configuration');
return c.json(
{ error: 'OAuth configuration incomplete' },
{ status: 500 }
);
}
console.log('[Auth] Exchanging authorization code for token');
// Step 1: Exchange code for token with Microsoft
const tokenParams = new URLSearchParams({
client_id: MICROSOFT_CLIENT_ID,
client_secret: MICROSOFT_CLIENT_SECRET,
code: code,
code_verifier: codeVerifier,
redirect_uri: REDIRECT_URI,
grant_type: 'authorization_code',
scope: 'offline_access',
});
const tokenResponse = await fetch(TOKEN_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: tokenParams.toString(),
});
if (!tokenResponse.ok) {
const error = await tokenResponse.text();
console.error('[Auth] Token exchange failed:', error);
return c.json(
{ error: 'Failed to exchange code for token', details: error },
{ status: 400 }
);
}
const tokenData = await tokenResponse.json() as any;
const accessToken = tokenData.access_token;
const refreshToken = tokenData.refresh_token;
const expiresIn = tokenData.expires_in || 3600;
if (!accessToken) {
console.error('[Auth] No access token in response');
return c.json(
{ error: 'No access token in response' },
{ status: 400 }
);
}
console.log('[Auth] Successfully exchanged code for token');
// Step 2: Get user profile from Microsoft Graph
const userResponse = await fetch(GRAPH_ENDPOINT, {
method: 'GET',
headers: { Authorization: `Bearer ${accessToken}` },
});
if (!userResponse.ok) {
console.error('[Auth] Failed to fetch user profile');
return c.json(
{ error: 'Failed to fetch user profile' },
{ status: 400 }
);
}
const user = await userResponse.json() as any;
console.log('[Auth] Retrieved user profile:', user.displayName);
// Step 3: Store refresh token server-side
if (refreshToken) {
const expiresAt = Date.now() + (tokenData.refresh_token_expires_in || 90 * 24 * 60 * 60) * 1000;
tokenStore.set(user.id, { refreshToken, expiresAt });
console.log('[Auth] Stored refresh token for user:', user.id);
}
// Step 4: Return response
const response: TokenResponse = {
accessToken,
refreshToken: refreshToken || '',
expiresIn,
user: {
id: user.id,
displayName: user.displayName,
mail: user.mail,
},
};
return c.json(response);
} catch (error) {
console.error('[Auth] Authorization handler error:', error);
return c.json(
{ error: 'Internal server error', details: (error as Error).message },
{ status: 500 }
);
}
}
// ============================================================================
// TOKEN REFRESH ENDPOINT
// ============================================================================
/**
* POST /api/auth/refresh
*
* Refresh access token using refresh token
*
* REQUEST:
* {
* refreshToken: string
* }
*
* RESPONSE:
* {
* accessToken: string
* expiresIn: number (seconds)
* }
*/
export async function handleRefresh(c: Context): Promise<Response> {
try {
const body = await c.req.json() as RefreshTokenRequest;
const { refreshToken } = body;
if (!refreshToken) {
return c.json(
{ error: 'Refresh token required' },
{ status: 400 }
);
}
console.log('[Auth] Refreshing access token');
// Exchange refresh token for new access token
const tokenParams = new URLSearchParams({
client_id: MICROSOFT_CLIENT_ID,
client_secret: MICROSOFT_CLIENT_SECRET,
refresh_token: refreshToken,
grant_type: 'refresh_token',
scope: 'offline_access',
});
const tokenResponse = await fetch(TOKEN_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: tokenParams.toString(),
});
if (!tokenResponse.ok) {
const error = await tokenResponse.text();
console.error('[Auth] Token refresh failed:', error);
return c.json(
{ error: 'Failed to refresh token' },
{ status: 401 }
);
}
const tokenData = await tokenResponse.json() as any;
const newAccessToken = tokenData.access_token;
const expiresIn = tokenData.expires_in || 3600;
console.log('[Auth] Successfully refreshed access token');
return c.json({
accessToken: newAccessToken,
expiresIn,
});
} catch (error) {
console.error('[Auth] Refresh handler error:', error);
return c.json(
{ error: 'Internal server error', details: (error as Error).message },
{ status: 500 }
);
}
}
// ============================================================================
// LOGOUT ENDPOINT
// ============================================================================
/**
* POST /api/auth/logout
*
* Logout and invalidate tokens
*/
export async function handleLogout(c: Context): Promise<Response> {
try {
console.log('[Auth] Logout requested');
// In production, would invalidate refresh token in database
// For now, just return success
return c.json({ success: true });
} catch (error) {
console.error('[Auth] Logout handler error:', error);
return c.json(
{ error: 'Internal server error' },
{ status: 500 }
);
}
}