Add OAuth 2.0 authentication support for Jira Data Center
- Add OAuth 2.0 configuration options in backend env.ts - Create authService.ts for OAuth flow, token management, and sessions - Create auth.ts routes for login, callback, logout, and user info - Update JiraAssets service to use user tokens when OAuth is enabled - Add cookie-parser for session handling - Create Login.tsx component with Jira OAuth login button - Add authStore.ts (Zustand) for frontend auth state management - Update App.tsx to show login page when OAuth is enabled - Add user menu with logout functionality - Document OAuth setup in CLAUDE.md Supports two modes: 1. Service Account: Uses JIRA_PAT for all requests (default) 2. OAuth 2.0: Each user authenticates with their Jira credentials
This commit is contained in:
@@ -11,6 +11,7 @@
|
||||
"dependencies": {
|
||||
"@anthropic-ai/sdk": "^0.32.1",
|
||||
"better-sqlite3": "^11.6.0",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "^16.4.5",
|
||||
"express": "^4.21.1",
|
||||
@@ -22,6 +23,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/better-sqlite3": "^7.6.12",
|
||||
"@types/cookie-parser": "^1.4.10",
|
||||
"@types/cors": "^2.8.17",
|
||||
"@types/express": "^5.0.0",
|
||||
"@types/node": "^22.9.0",
|
||||
|
||||
@@ -10,6 +10,16 @@ interface Config {
|
||||
jiraPat: string;
|
||||
jiraSchemaId: string;
|
||||
|
||||
// Jira OAuth 2.0 Configuration
|
||||
jiraOAuthEnabled: boolean;
|
||||
jiraOAuthClientId: string;
|
||||
jiraOAuthClientSecret: string;
|
||||
jiraOAuthCallbackUrl: string;
|
||||
jiraOAuthScopes: string;
|
||||
|
||||
// Session Configuration
|
||||
sessionSecret: string;
|
||||
|
||||
// Object Type IDs
|
||||
jiraApplicationComponentTypeId: string;
|
||||
jiraApplicationFunctionTypeId: string;
|
||||
@@ -56,6 +66,7 @@ interface Config {
|
||||
nodeEnv: string;
|
||||
isDevelopment: boolean;
|
||||
isProduction: boolean;
|
||||
frontendUrl: string;
|
||||
|
||||
// API Configuration
|
||||
jiraApiBatchSize: number;
|
||||
@@ -79,6 +90,16 @@ export const config: Config = {
|
||||
jiraPat: getOptionalEnvVar('JIRA_PAT'),
|
||||
jiraSchemaId: getOptionalEnvVar('JIRA_SCHEMA_ID'),
|
||||
|
||||
// Jira OAuth 2.0 Configuration
|
||||
jiraOAuthEnabled: getOptionalEnvVar('JIRA_OAUTH_ENABLED', 'false').toLowerCase() === 'true',
|
||||
jiraOAuthClientId: getOptionalEnvVar('JIRA_OAUTH_CLIENT_ID'),
|
||||
jiraOAuthClientSecret: getOptionalEnvVar('JIRA_OAUTH_CLIENT_SECRET'),
|
||||
jiraOAuthCallbackUrl: getOptionalEnvVar('JIRA_OAUTH_CALLBACK_URL', 'http://localhost:3001/api/auth/callback'),
|
||||
jiraOAuthScopes: getOptionalEnvVar('JIRA_OAUTH_SCOPES', 'READ WRITE'),
|
||||
|
||||
// Session Configuration
|
||||
sessionSecret: getOptionalEnvVar('SESSION_SECRET', 'change-this-secret-in-production'),
|
||||
|
||||
// Object Type IDs
|
||||
jiraApplicationComponentTypeId: getOptionalEnvVar('JIRA_APPLICATION_COMPONENT_TYPE_ID'),
|
||||
jiraApplicationFunctionTypeId: getOptionalEnvVar('JIRA_APPLICATION_FUNCTION_TYPE_ID'),
|
||||
@@ -125,6 +146,7 @@ export const config: Config = {
|
||||
nodeEnv: getOptionalEnvVar('NODE_ENV', 'development'),
|
||||
isDevelopment: getOptionalEnvVar('NODE_ENV', 'development') === 'development',
|
||||
isProduction: getOptionalEnvVar('NODE_ENV', 'development') === 'production',
|
||||
frontendUrl: getOptionalEnvVar('FRONTEND_URL', 'http://localhost:5173'),
|
||||
|
||||
// API Configuration
|
||||
jiraApiBatchSize: parseInt(getOptionalEnvVar('JIRA_API_BATCH_SIZE', '15'), 10),
|
||||
|
||||
@@ -2,6 +2,7 @@ import express from 'express';
|
||||
import cors from 'cors';
|
||||
import helmet from 'helmet';
|
||||
import rateLimit from 'express-rate-limit';
|
||||
import cookieParser from 'cookie-parser';
|
||||
import { config, validateConfig } from './config/env.js';
|
||||
import { logger } from './services/logger.js';
|
||||
import { dataService } from './services/dataService.js';
|
||||
@@ -10,6 +11,8 @@ import classificationsRouter from './routes/classifications.js';
|
||||
import referenceDataRouter from './routes/referenceData.js';
|
||||
import dashboardRouter from './routes/dashboard.js';
|
||||
import configurationRouter from './routes/configuration.js';
|
||||
import authRouter, { authMiddleware } from './routes/auth.js';
|
||||
import { jiraAssetsService } from './services/jiraAssets.js';
|
||||
|
||||
// Validate configuration
|
||||
validateConfig();
|
||||
@@ -19,10 +22,13 @@ const app = express();
|
||||
// Security middleware
|
||||
app.use(helmet());
|
||||
app.use(cors({
|
||||
origin: config.isDevelopment ? '*' : ['http://localhost:5173', 'http://localhost:3000'],
|
||||
origin: config.isDevelopment ? ['http://localhost:5173', 'http://localhost:3000'] : [config.frontendUrl],
|
||||
credentials: true,
|
||||
}));
|
||||
|
||||
// Cookie parser for session handling
|
||||
app.use(cookieParser());
|
||||
|
||||
// Rate limiting
|
||||
const limiter = rateLimit({
|
||||
windowMs: 15 * 60 * 1000, // 15 minutes
|
||||
@@ -41,6 +47,24 @@ app.use((req, res, next) => {
|
||||
next();
|
||||
});
|
||||
|
||||
// Auth middleware - extract session info for all requests
|
||||
app.use(authMiddleware);
|
||||
|
||||
// Set user token on JiraAssets service for each request
|
||||
app.use((req, res, next) => {
|
||||
// Set user's OAuth token if available
|
||||
if (req.accessToken) {
|
||||
jiraAssetsService.setRequestToken(req.accessToken);
|
||||
}
|
||||
|
||||
// Clear token after response is sent
|
||||
res.on('finish', () => {
|
||||
jiraAssetsService.clearRequestToken();
|
||||
});
|
||||
|
||||
next();
|
||||
});
|
||||
|
||||
// Health check
|
||||
app.get('/health', async (req, res) => {
|
||||
const jiraConnected = await dataService.testConnection();
|
||||
@@ -61,6 +85,7 @@ app.get('/api/config', (req, res) => {
|
||||
});
|
||||
|
||||
// API routes
|
||||
app.use('/api/auth', authRouter);
|
||||
app.use('/api/applications', applicationsRouter);
|
||||
app.use('/api/classifications', classificationsRouter);
|
||||
app.use('/api/reference-data', referenceDataRouter);
|
||||
|
||||
190
backend/src/routes/auth.ts
Normal file
190
backend/src/routes/auth.ts
Normal file
@@ -0,0 +1,190 @@
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import { authService, JiraUser } from '../services/authService.js';
|
||||
import { config } from '../config/env.js';
|
||||
import { logger } from '../services/logger.js';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// Extend Express Request to include user info
|
||||
declare global {
|
||||
namespace Express {
|
||||
interface Request {
|
||||
sessionId?: string;
|
||||
user?: JiraUser;
|
||||
accessToken?: string;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Get auth configuration
|
||||
router.get('/config', (req: Request, res: Response) => {
|
||||
res.json({
|
||||
oauthEnabled: authService.isOAuthEnabled(),
|
||||
serviceAccountEnabled: authService.isUsingServiceAccount(),
|
||||
jiraHost: config.jiraHost,
|
||||
});
|
||||
});
|
||||
|
||||
// Get current user (check if logged in)
|
||||
router.get('/me', (req: Request, res: Response) => {
|
||||
const sessionId = req.headers['x-session-id'] as string || req.cookies?.sessionId;
|
||||
|
||||
if (!sessionId) {
|
||||
// If OAuth not enabled, allow anonymous access with service account
|
||||
if (authService.isUsingServiceAccount() && !authService.isOAuthEnabled()) {
|
||||
return res.json({
|
||||
authenticated: true,
|
||||
authMethod: 'service-account',
|
||||
user: {
|
||||
accountId: 'service-account',
|
||||
displayName: 'Service Account',
|
||||
},
|
||||
});
|
||||
}
|
||||
return res.json({ authenticated: false });
|
||||
}
|
||||
|
||||
const user = authService.getUser(sessionId);
|
||||
if (!user) {
|
||||
return res.json({ authenticated: false });
|
||||
}
|
||||
|
||||
res.json({
|
||||
authenticated: true,
|
||||
authMethod: 'oauth',
|
||||
user,
|
||||
});
|
||||
});
|
||||
|
||||
// Initiate OAuth login
|
||||
router.get('/login', (req: Request, res: Response) => {
|
||||
if (!authService.isOAuthEnabled()) {
|
||||
return res.status(400).json({ error: 'OAuth is not enabled' });
|
||||
}
|
||||
|
||||
try {
|
||||
const { url, state } = authService.getAuthorizationUrl();
|
||||
|
||||
// Store state in cookie for verification (httpOnly for security)
|
||||
res.cookie('oauth_state', state, {
|
||||
httpOnly: true,
|
||||
secure: config.isProduction,
|
||||
sameSite: 'lax',
|
||||
maxAge: 10 * 60 * 1000, // 10 minutes
|
||||
});
|
||||
|
||||
logger.info('Redirecting to Jira OAuth...');
|
||||
res.redirect(url);
|
||||
} catch (error) {
|
||||
logger.error('Failed to initiate OAuth login:', error);
|
||||
res.status(500).json({ error: 'Failed to initiate login' });
|
||||
}
|
||||
});
|
||||
|
||||
// OAuth callback
|
||||
router.get('/callback', async (req: Request, res: Response) => {
|
||||
const { code, state, error, error_description } = req.query;
|
||||
|
||||
// Handle OAuth errors
|
||||
if (error) {
|
||||
logger.error(`OAuth error: ${error} - ${error_description}`);
|
||||
return res.redirect(`${config.frontendUrl}/login?error=${encodeURIComponent(String(error_description || error))}`);
|
||||
}
|
||||
|
||||
// Validate required parameters
|
||||
if (!code || !state) {
|
||||
return res.redirect(`${config.frontendUrl}/login?error=${encodeURIComponent('Missing authorization code or state')}`);
|
||||
}
|
||||
|
||||
try {
|
||||
// Exchange code for tokens
|
||||
const { sessionId, user } = await authService.exchangeCodeForTokens(
|
||||
String(code),
|
||||
String(state)
|
||||
);
|
||||
|
||||
logger.info(`OAuth login successful for: ${user.displayName}`);
|
||||
|
||||
// Set session cookie
|
||||
res.cookie('sessionId', sessionId, {
|
||||
httpOnly: true,
|
||||
secure: config.isProduction,
|
||||
sameSite: 'lax',
|
||||
maxAge: 24 * 60 * 60 * 1000, // 24 hours
|
||||
});
|
||||
|
||||
// Redirect to frontend with session info
|
||||
res.redirect(`${config.frontendUrl}?login=success`);
|
||||
} catch (error) {
|
||||
logger.error('OAuth callback error:', error);
|
||||
const errorMessage = error instanceof Error ? error.message : 'Authentication failed';
|
||||
res.redirect(`${config.frontendUrl}/login?error=${encodeURIComponent(errorMessage)}`);
|
||||
}
|
||||
});
|
||||
|
||||
// Logout
|
||||
router.post('/logout', (req: Request, res: Response) => {
|
||||
const sessionId = req.headers['x-session-id'] as string || req.cookies?.sessionId;
|
||||
|
||||
if (sessionId) {
|
||||
authService.logout(sessionId);
|
||||
}
|
||||
|
||||
// Clear cookies
|
||||
res.clearCookie('sessionId');
|
||||
res.clearCookie('oauth_state');
|
||||
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
// Refresh token
|
||||
router.post('/refresh', async (req: Request, res: Response) => {
|
||||
const sessionId = req.headers['x-session-id'] as string || req.cookies?.sessionId;
|
||||
|
||||
if (!sessionId) {
|
||||
return res.status(401).json({ error: 'Not authenticated' });
|
||||
}
|
||||
|
||||
const success = await authService.refreshAccessToken(sessionId);
|
||||
|
||||
if (success) {
|
||||
res.json({ success: true });
|
||||
} else {
|
||||
res.status(401).json({ error: 'Failed to refresh token' });
|
||||
}
|
||||
});
|
||||
|
||||
// Middleware to extract session and attach user to request
|
||||
export function authMiddleware(req: Request, res: Response, next: NextFunction) {
|
||||
const sessionId = req.headers['x-session-id'] as string || req.cookies?.sessionId;
|
||||
|
||||
if (sessionId) {
|
||||
const session = authService.getSession(sessionId);
|
||||
if (session) {
|
||||
req.sessionId = sessionId;
|
||||
req.user = session.user;
|
||||
req.accessToken = session.accessToken;
|
||||
}
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
// Middleware to require authentication
|
||||
export function requireAuth(req: Request, res: Response, next: NextFunction) {
|
||||
// If OAuth is enabled, require a valid session
|
||||
if (authService.isOAuthEnabled()) {
|
||||
if (!req.user) {
|
||||
return res.status(401).json({ error: 'Authentication required' });
|
||||
}
|
||||
}
|
||||
// If only service account is configured, allow through
|
||||
else if (!authService.isUsingServiceAccount()) {
|
||||
return res.status(503).json({ error: 'No authentication method configured' });
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
|
||||
export default router;
|
||||
|
||||
283
backend/src/services/authService.ts
Normal file
283
backend/src/services/authService.ts
Normal file
@@ -0,0 +1,283 @@
|
||||
import { config } from '../config/env.js';
|
||||
import { logger } from './logger.js';
|
||||
import { randomBytes, createHash } from 'crypto';
|
||||
|
||||
// Token storage (in production, use Redis or similar)
|
||||
interface UserSession {
|
||||
accessToken: string;
|
||||
refreshToken?: string;
|
||||
expiresAt: number;
|
||||
user: JiraUser;
|
||||
}
|
||||
|
||||
export interface JiraUser {
|
||||
accountId: string;
|
||||
displayName: string;
|
||||
emailAddress?: string;
|
||||
avatarUrl?: string;
|
||||
}
|
||||
|
||||
// In-memory session store (replace with Redis in production)
|
||||
const sessionStore = new Map<string, UserSession>();
|
||||
|
||||
// Session cleanup interval (every 5 minutes)
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
for (const [sessionId, session] of sessionStore.entries()) {
|
||||
if (session.expiresAt < now) {
|
||||
sessionStore.delete(sessionId);
|
||||
logger.debug(`Cleaned up expired session: ${sessionId.substring(0, 8)}...`);
|
||||
}
|
||||
}
|
||||
}, 5 * 60 * 1000);
|
||||
|
||||
// PKCE helpers for OAuth 2.0
|
||||
export function generateCodeVerifier(): string {
|
||||
return randomBytes(32).toString('base64url');
|
||||
}
|
||||
|
||||
export function generateCodeChallenge(verifier: string): string {
|
||||
return createHash('sha256').update(verifier).digest('base64url');
|
||||
}
|
||||
|
||||
// Generate state parameter to prevent CSRF
|
||||
export function generateState(): string {
|
||||
return randomBytes(16).toString('hex');
|
||||
}
|
||||
|
||||
// Store for OAuth state and PKCE verifiers (temporary, during auth flow)
|
||||
const authFlowStore = new Map<string, { codeVerifier: string; createdAt: number }>();
|
||||
|
||||
// Clean up old auth flow entries
|
||||
setInterval(() => {
|
||||
const now = Date.now();
|
||||
const maxAge = 10 * 60 * 1000; // 10 minutes
|
||||
for (const [state, flow] of authFlowStore.entries()) {
|
||||
if (now - flow.createdAt > maxAge) {
|
||||
authFlowStore.delete(state);
|
||||
}
|
||||
}
|
||||
}, 60 * 1000);
|
||||
|
||||
class AuthService {
|
||||
// Get OAuth authorization URL
|
||||
getAuthorizationUrl(): { url: string; state: string } {
|
||||
const state = generateState();
|
||||
const codeVerifier = generateCodeVerifier();
|
||||
const codeChallenge = generateCodeChallenge(codeVerifier);
|
||||
|
||||
// Store code verifier for later use in token exchange
|
||||
authFlowStore.set(state, { codeVerifier, createdAt: Date.now() });
|
||||
|
||||
// Build authorization URL for Jira Data Center OAuth 2.0
|
||||
const params = new URLSearchParams({
|
||||
client_id: config.jiraOAuthClientId,
|
||||
redirect_uri: config.jiraOAuthCallbackUrl,
|
||||
response_type: 'code',
|
||||
scope: config.jiraOAuthScopes,
|
||||
state: state,
|
||||
code_challenge: codeChallenge,
|
||||
code_challenge_method: 'S256',
|
||||
});
|
||||
|
||||
const authUrl = `${config.jiraHost}/rest/oauth2/latest/authorize?${params.toString()}`;
|
||||
|
||||
logger.info(`Generated OAuth authorization URL with state: ${state}`);
|
||||
return { url: authUrl, state };
|
||||
}
|
||||
|
||||
// Exchange authorization code for tokens
|
||||
async exchangeCodeForTokens(code: string, state: string): Promise<{ sessionId: string; user: JiraUser }> {
|
||||
// Retrieve and validate state
|
||||
const flowData = authFlowStore.get(state);
|
||||
if (!flowData) {
|
||||
throw new Error('Invalid or expired state parameter');
|
||||
}
|
||||
authFlowStore.delete(state);
|
||||
|
||||
const tokenUrl = `${config.jiraHost}/rest/oauth2/latest/token`;
|
||||
|
||||
const body = new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
client_id: config.jiraOAuthClientId,
|
||||
client_secret: config.jiraOAuthClientSecret,
|
||||
code: code,
|
||||
redirect_uri: config.jiraOAuthCallbackUrl,
|
||||
code_verifier: flowData.codeVerifier,
|
||||
});
|
||||
|
||||
logger.info('Exchanging authorization code for tokens...');
|
||||
|
||||
const response = await fetch(tokenUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: body.toString(),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
logger.error(`Token exchange failed: ${response.status} ${errorText}`);
|
||||
throw new Error(`Token exchange failed: ${response.status}`);
|
||||
}
|
||||
|
||||
const tokenData = await response.json() as {
|
||||
access_token: string;
|
||||
refresh_token?: string;
|
||||
expires_in: number;
|
||||
token_type: string;
|
||||
};
|
||||
|
||||
// Fetch user info
|
||||
const user = await this.fetchUserInfo(tokenData.access_token);
|
||||
|
||||
// Create session
|
||||
const sessionId = randomBytes(32).toString('hex');
|
||||
const session: UserSession = {
|
||||
accessToken: tokenData.access_token,
|
||||
refreshToken: tokenData.refresh_token,
|
||||
expiresAt: Date.now() + (tokenData.expires_in * 1000),
|
||||
user,
|
||||
};
|
||||
|
||||
sessionStore.set(sessionId, session);
|
||||
logger.info(`Created session for user: ${user.displayName}`);
|
||||
|
||||
return { sessionId, user };
|
||||
}
|
||||
|
||||
// Fetch current user info from Jira
|
||||
async fetchUserInfo(accessToken: string): Promise<JiraUser> {
|
||||
const response = await fetch(`${config.jiraHost}/rest/api/2/myself`, {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${accessToken}`,
|
||||
'Accept': 'application/json',
|
||||
},
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch user info: ${response.status}`);
|
||||
}
|
||||
|
||||
const userData = await response.json() as {
|
||||
accountId?: string;
|
||||
key?: string;
|
||||
name?: string;
|
||||
displayName: string;
|
||||
emailAddress?: string;
|
||||
avatarUrls?: { '48x48'?: string };
|
||||
};
|
||||
|
||||
return {
|
||||
accountId: userData.accountId || userData.key || userData.name || 'unknown',
|
||||
displayName: userData.displayName,
|
||||
emailAddress: userData.emailAddress,
|
||||
avatarUrl: userData.avatarUrls?.['48x48'],
|
||||
};
|
||||
}
|
||||
|
||||
// Get session by ID
|
||||
getSession(sessionId: string): UserSession | null {
|
||||
const session = sessionStore.get(sessionId);
|
||||
if (!session) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Check if expired
|
||||
if (session.expiresAt < Date.now()) {
|
||||
sessionStore.delete(sessionId);
|
||||
return null;
|
||||
}
|
||||
|
||||
return session;
|
||||
}
|
||||
|
||||
// Get access token for a session
|
||||
getAccessToken(sessionId: string): string | null {
|
||||
const session = this.getSession(sessionId);
|
||||
return session?.accessToken || null;
|
||||
}
|
||||
|
||||
// Get user for a session
|
||||
getUser(sessionId: string): JiraUser | null {
|
||||
const session = this.getSession(sessionId);
|
||||
return session?.user || null;
|
||||
}
|
||||
|
||||
// Refresh access token
|
||||
async refreshAccessToken(sessionId: string): Promise<boolean> {
|
||||
const session = sessionStore.get(sessionId);
|
||||
if (!session?.refreshToken) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const tokenUrl = `${config.jiraHost}/rest/oauth2/latest/token`;
|
||||
|
||||
const body = new URLSearchParams({
|
||||
grant_type: 'refresh_token',
|
||||
client_id: config.jiraOAuthClientId,
|
||||
client_secret: config.jiraOAuthClientSecret,
|
||||
refresh_token: session.refreshToken,
|
||||
});
|
||||
|
||||
try {
|
||||
const response = await fetch(tokenUrl, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: body.toString(),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
logger.warn(`Token refresh failed: ${response.status}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const tokenData = await response.json() as {
|
||||
access_token: string;
|
||||
refresh_token?: string;
|
||||
expires_in: number;
|
||||
};
|
||||
|
||||
// Update session
|
||||
session.accessToken = tokenData.access_token;
|
||||
if (tokenData.refresh_token) {
|
||||
session.refreshToken = tokenData.refresh_token;
|
||||
}
|
||||
session.expiresAt = Date.now() + (tokenData.expires_in * 1000);
|
||||
|
||||
sessionStore.set(sessionId, session);
|
||||
logger.info(`Refreshed token for session: ${sessionId.substring(0, 8)}...`);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
logger.error('Token refresh error:', error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Logout / destroy session
|
||||
logout(sessionId: string): boolean {
|
||||
const existed = sessionStore.has(sessionId);
|
||||
sessionStore.delete(sessionId);
|
||||
if (existed) {
|
||||
logger.info(`Logged out session: ${sessionId.substring(0, 8)}...`);
|
||||
}
|
||||
return existed;
|
||||
}
|
||||
|
||||
// Check if OAuth is enabled
|
||||
isOAuthEnabled(): boolean {
|
||||
return config.jiraOAuthEnabled && !!config.jiraOAuthClientId && !!config.jiraOAuthClientSecret;
|
||||
}
|
||||
|
||||
// Check if using service account (PAT) fallback
|
||||
isUsingServiceAccount(): boolean {
|
||||
return !this.isOAuthEnabled() && !!config.jiraPat;
|
||||
}
|
||||
}
|
||||
|
||||
export const authService = new AuthService();
|
||||
|
||||
@@ -75,8 +75,10 @@ interface ObjectTypeAttributeDefinition {
|
||||
class JiraAssetsService {
|
||||
private insightBaseUrl: string;
|
||||
private assetsBaseUrl: string;
|
||||
private headers: Record<string, string>;
|
||||
private defaultHeaders: Record<string, string>;
|
||||
private isDataCenter: boolean | null = null;
|
||||
// Request-scoped user token (set per request via middleware)
|
||||
private requestToken: string | null = null;
|
||||
// Cache: objectTypeName -> Map<attributeId, attributeName>
|
||||
private attributeSchemaCache: Map<string, Map<number, string>> = new Map();
|
||||
// Cache: Application functions with full details (applicationFunctionCategory, description, keywords)
|
||||
@@ -109,13 +111,33 @@ class JiraAssetsService {
|
||||
// Try both API paths - Insight (Data Center) and Assets (Cloud)
|
||||
this.insightBaseUrl = `${config.jiraHost}/rest/insight/1.0`;
|
||||
this.assetsBaseUrl = `${config.jiraHost}/rest/assets/1.0`;
|
||||
this.headers = {
|
||||
this.defaultHeaders = {
|
||||
Authorization: `Bearer ${config.jiraPat}`,
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
};
|
||||
}
|
||||
|
||||
// Set user token for the current request (call this from middleware)
|
||||
setRequestToken(token: string | null): void {
|
||||
this.requestToken = token;
|
||||
}
|
||||
|
||||
// Clear request token (call after request completes)
|
||||
clearRequestToken(): void {
|
||||
this.requestToken = null;
|
||||
}
|
||||
|
||||
// Get headers with the appropriate token (user token takes precedence)
|
||||
private get headers(): Record<string, string> {
|
||||
const token = this.requestToken || config.jiraPat;
|
||||
return {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
};
|
||||
}
|
||||
|
||||
private getBaseUrl(): string {
|
||||
// Use detected API type or default to Insight (Data Center)
|
||||
return this.isDataCenter === false ? this.assetsBaseUrl : this.insightBaseUrl;
|
||||
|
||||
Reference in New Issue
Block a user