42ff3e8f33
Build & Deploy / Test & Lint (push) Has been cancelled
Build & Deploy / Security Scan (push) Has been cancelled
Code Quality / Code Quality Checks (push) Has been cancelled
Code Quality / Dependency Vulnerabilities (push) Has been cancelled
Code Quality / Performance Testing (push) Has been cancelled
Build & Deploy / Build Docker Images (push) Has been cancelled
Build & Deploy / Deploy to Kubernetes (push) Has been cancelled
297 lines
8.2 KiB
TypeScript
Executable File
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 }
|
|
);
|
|
}
|
|
}
|