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:
2026-01-06 15:40:52 +01:00
parent 0b27adc2fb
commit ea1c84262c
11 changed files with 1016 additions and 10 deletions

View File

@@ -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",

View File

@@ -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),

View File

@@ -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
View 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;

View 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();

View File

@@ -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;