Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,8 @@ VITE_AUTH_SERVER_URL=http://localhost:3000
ANTHROPIC_API_KEY=sk-ant-...

# --- Testing & Development Variables ---
# Forces short (1-minute) JWT expiration for testing refresh flows
# TEST_SHORT_AUTH_TOKEN_EXP=60
# Forces short (6-minute) JWT expiration for testing refresh flows
# TEST_SHORT_AUTH_TOKEN_EXP=360
# Enable JWT expiration checking (set to false to disable in development)
# CHECK_JWT_EXPIRATION=true
# Override cache directory location (development only)
Expand Down
20 changes: 14 additions & 6 deletions server/auth/consent-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
*/

import type { Request, Response } from 'express';
import { createJiraMCPAuthToken } from '../pkce/token-helpers.js';
import { createJiraMCPAuthToken, createJiraMCPRefreshToken } from '../pkce/token-helpers.js';
import { generateAuthorizationCode, storeAuthorizationCode } from '../pkce/authorization-code-store.js';

/**
Expand Down Expand Up @@ -217,17 +217,24 @@ export async function handleConnectionDone(req: Request, res: Response): Promise
}

// Create JWT - the createJiraMCPAuthToken function already creates nested structure
const jwt = await createJiraMCPAuthToken({
const atlassianTokenResponse = {
access_token: atlassianTokens.access_token,
refresh_token: atlassianTokens.refresh_token || '',
token_type: 'Bearer',
token_type: 'Bearer' as const,
expires_in: Math.floor((atlassianTokens.expires_at - Date.now()) / 1000),
scope: atlassianTokens.scope || '',
}, {
};

const jwt = await createJiraMCPAuthToken(atlassianTokenResponse, {
resource: req.session.mcpResource
});

console.log(' JWT created successfully');
// Create refresh token JWT
const { refreshToken } = await createJiraMCPRefreshToken(atlassianTokenResponse, {
resource: req.session.mcpResource
});

console.log(' JWT access and refresh tokens created successfully');

// Clear session provider data (tokens now embedded in JWT)
delete req.session.providerTokens;
Expand All @@ -238,11 +245,12 @@ export async function handleConnectionDone(req: Request, res: Response): Promise
// Check if this was initiated by an MCP client (has redirect URI)
if (req.session.mcpRedirectUri && req.session.usingMcpPkce) {
// OAuth 2.0 Authorization Code Flow (RFC 6749 Section 4.1.2)
// Generate authorization code and store JWT mapping
// Generate authorization code and store JWT mapping (both access and refresh tokens)
const authCode = generateAuthorizationCode();
storeAuthorizationCode(
authCode,
jwt,
refreshToken,
req.session.mcpClientId,
req.session.mcpRedirectUri
);
Expand Down
95 changes: 67 additions & 28 deletions server/pkce/access-token.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@
import { Request, Response } from 'express';
import {
sanitizeObjectWithJWTs,
parseJWT,
} from '../tokens.ts';
import {
createJiraMCPAuthToken,
Expand Down Expand Up @@ -58,6 +59,24 @@ function sendErrorResponse(res: Response, error: string, description: string, st
res.status(statusCode).json(errorResponse);
}

/**
* Extract expires_in from a JWT token by decoding its exp claim
* @param token - JWT token string
* @returns seconds until expiration, or default 3540 if unable to extract
*/
function getExpiresInFromJwt(token: string): number {
try {
const payload = parseJWT(token);
if (payload.exp && typeof payload.exp === 'number') {
const expiresIn = payload.exp - Math.floor(Date.now() / 1000);
return Math.max(0, expiresIn); // Don't return negative
}
} catch (err) {
console.log(' Warning: Could not extract exp from JWT, using default');
}
return 3540; // Default fallback
}

/**
* Internal function to handle authorization code grant
*/
Expand All @@ -75,21 +94,41 @@ async function handleAuthorizationCodeGrant(

// First, check if this is a connection hub authorization code
// Connection hub codes are generated by our server and map directly to JWTs
const connectionHubJwt = consumeAuthorizationCode(code);

if (connectionHubJwt) {
console.log(' 🔗 Connection hub authorization code - returning stored JWT');

// We already have the JWT from the connection hub flow
// No need to exchange with Atlassian - tokens are already embedded
// Note: Connection hub JWT already contains refresh token embedded

res.json({
access_token: connectionHubJwt,
const result = consumeAuthorizationCode(code);

if (result) {
console.log(' 🔗 Connection hub authorization code - returning stored JWTs');
console.log(` Has access token: ${!!result.accessToken}`);
console.log(` Has refresh token: ${!!result.refreshToken}`);

// We already have the JWTs from the connection hub flow
// No need to exchange with provider - tokens are already embedded
// Extract expires_in from the JWT's exp claim
const expiresIn = getExpiresInFromJwt(result.accessToken);

const response: any = {
access_token: result.accessToken,
token_type: 'Bearer',
expires_in: 3540, // JWT expiration (will be validated by client)
expires_in: expiresIn,
scope: getAtlassianConfig().scopes,
});
};

console.log(` Connection hub response expires_in: ${expiresIn}s (${Math.floor(expiresIn / 60)}m ${expiresIn % 60}s)`);
console.log(` JWT exp timestamp: ${parseJWT(result.accessToken).exp}`);
console.log(` Current timestamp: ${Math.floor(Date.now() / 1000)}`);

// Include refresh_token if present
if (result.refreshToken) {
response.refresh_token = result.refreshToken;
}

console.log(` 📤 Full response to VS Code:`, JSON.stringify({
...response,
access_token: response.access_token.substring(0, 50) + '...',
refresh_token: response.refresh_token ? response.refresh_token.substring(0, 50) + '...' : undefined,
}, null, 2));

res.json(response);
return;
}

Expand Down Expand Up @@ -128,8 +167,10 @@ async function handleAuthorizationCodeGrant(
resource: resource || process.env.VITE_AUTH_SERVER_URL
});

// Return OAuth-compliant response with actual JWT expiration time
const jwtExpiresIn = Math.max(60, (tokenData.expires_in || 3600) - 60);
// Extract expires_in from the JWT's exp claim (respects TEST_SHORT_AUTH_TOKEN_EXP)
const jwtExpiresIn = getExpiresInFromJwt(jwt);
console.log(` Response expires_in: ${jwtExpiresIn}s`);

res.json({
access_token: jwt,
token_type: 'Bearer',
Expand All @@ -151,19 +192,17 @@ async function handleRefreshTokenGrant(

console.log('🔄 REFRESH TOKEN FLOW - Routing refresh token request from /access-token to refresh handler');

// Reconstruct the request object for the refresh token handler
const refreshReq = {
...req,
body: {
grant_type: 'refresh_token',
refresh_token,
client_id,
scope: req.body.scope,
},
} as OAuthRequest;

// Import and call the refresh token handler
await refreshToken(refreshReq, res);
// Modify the request body for the refresh token handler
// We mutate the original request since Express handlers expect the full Request object
req.body = {
grant_type: 'refresh_token',
refresh_token,
client_id,
scope: req.body.scope,
};

// Call the refresh token handler with the original request
await refreshToken(req, res);
}

/**
Expand Down
60 changes: 40 additions & 20 deletions server/pkce/authorization-code-store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,16 +19,25 @@
import crypto from 'crypto';

interface AuthCodeEntry {
jwt: string;
accessToken: string;
refreshToken?: string;
expiresAt: number;
clientId?: string;
redirectUri?: string;
}

/**
* Authorization code consumption result
*/
export interface AuthCodeResult {
accessToken: string;
refreshToken?: string;
}

/**
* In-memory store for authorization codes
* Key: authorization code
* Value: JWT token and metadata
* Value: JWT tokens (access and refresh) and metadata
*/
const authorizationCodes = new Map<string, AuthCodeEntry>();

Expand Down Expand Up @@ -69,59 +78,70 @@ export function generateAuthorizationCode(): string {
}

/**
* Store an authorization code with its associated JWT
*
* Store an authorization code with its associated JWT tokens
*
* @param code - The authorization code
* @param jwt - The JWT token to return when code is exchanged
* @param accessToken - The JWT access token to return when code is exchanged
* @param refreshToken - Optional JWT refresh token
* @param clientId - Optional client ID for validation
* @param redirectUri - Optional redirect URI for validation
*/
export function storeAuthorizationCode(
code: string,
jwt: string,
code: string,
accessToken: string,
refreshToken?: string,
clientId?: string,
redirectUri?: string
): void {
const expiresAt = Date.now() + CODE_EXPIRATION_MS;

authorizationCodes.set(code, {
jwt,
accessToken,
refreshToken,
expiresAt,
clientId,
redirectUri,
});

console.log(`📝 Stored authorization code (expires in ${CODE_EXPIRATION_MS / 1000}s)`);

console.log(
`📝 Stored authorization code (expires in ${CODE_EXPIRATION_MS / 1000}s)`
);
console.log(` Has refresh token: ${!!refreshToken}`);
}

/**
* Retrieve and consume an authorization code
*
*
* Per RFC 6749, authorization codes are single-use and must be deleted after retrieval.
*
*
* @param code - The authorization code to retrieve
* @returns The stored JWT, or null if code is invalid/expired
* @returns The stored access and refresh tokens, or null if code is invalid/expired
*/
export function consumeAuthorizationCode(code: string): string | null {
export function consumeAuthorizationCode(code: string): AuthCodeResult | null {
const entry = authorizationCodes.get(code);

if (!entry) {
console.log(' ❌ Authorization code not found');
return null;
}

// Check expiration
if (entry.expiresAt < Date.now()) {
authorizationCodes.delete(code);
console.log(' ⏰ Authorization code expired');
return null;
}

// Delete code (single-use per RFC 6749)
authorizationCodes.delete(code);
console.log(' ✅ Authorization code consumed');

return entry.jwt;
console.log(` Returning access token: ${!!entry.accessToken}`);
console.log(` Returning refresh token: ${!!entry.refreshToken}`);

return {
accessToken: entry.accessToken,
refreshToken: entry.refreshToken,
};
}

/**
Expand Down
37 changes: 32 additions & 5 deletions server/pkce/discovery.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,28 @@ const serverInstanceScope = /*`server-instance`;*/ `server-instance-${serverStar

export { serverStartTime, serverInstanceScope };

/**
* Get the base URL for OAuth metadata responses.
* In development with a proxy (like Vite), use the forwarded origin or request's origin/host.
* In production, use the configured VITE_AUTH_SERVER_URL.
*/
function getBaseUrl(req: Request): string {
// Check for forwarded origin header (set by Vite proxy)
const forwardedOrigin = req.headers['x-forwarded-origin'] as string | undefined;
if (forwardedOrigin) {
return forwardedOrigin;
}

// If request has an origin header (direct browser request), use it
const origin = req.headers.origin;
if (origin) {
return origin;
}

// Otherwise use configured URL or derive from request
return process.env.VITE_AUTH_SERVER_URL || `${req.protocol}://${req.get('host')}`;
}

/**
* OAuth Metadata Endpoint
* Provides OAuth server configuration for clients
Expand All @@ -47,11 +69,14 @@ export const oauthMetadata: OAuthHandler = (req: Request, res: Response): void =
res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');

const baseUrl = getBaseUrl(req);
console.log(' Using baseUrl:', baseUrl);

res.json({
issuer: process.env.VITE_AUTH_SERVER_URL,
authorization_endpoint: process.env.VITE_AUTH_SERVER_URL + '/auth/connect',
token_endpoint: process.env.VITE_AUTH_SERVER_URL + '/access-token',
registration_endpoint: process.env.VITE_AUTH_SERVER_URL + '/register',
issuer: baseUrl,
authorization_endpoint: baseUrl + '/auth/connect',
token_endpoint: baseUrl + '/access-token',
registration_endpoint: baseUrl + '/register',
code_challenge_methods_supported: ['S256'],
response_types_supported: ['code'],
grant_types_supported: ['authorization_code', 'refresh_token'],
Expand All @@ -75,7 +100,9 @@ export const oauthProtectedResourceMetadata: OAuthHandler = (req: Request, res:
res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');

const baseUrl = process.env.VITE_AUTH_SERVER_URL;
const baseUrl = getBaseUrl(req);
console.log(' Using baseUrl:', baseUrl);

const metadata = {
resource: baseUrl,
authorization_servers: [baseUrl],
Expand Down
Loading
Loading