import { createQueryStringHash , decodeAsymmetric , decodeSymmetric , getAlgorithm , getKeyId } from "atlassian-jwt" ; import { NextFunction , Request , Response } from "express" ; import envVars from "../../config/env" ; import _ from "lodash" ; import queryAtlassianConnectPublicKey from "./queryAtlassianConnectPublicKey" ; const JWT_PARAM = "jwt" ; const AUTH_HEADER = "authorization" ; const BASE_URL = envVars . APP_URL ; /**
 * Atlassian Connect has 2 different types of JWT tokens.
 * Normal tokens has qsh parameter and generated by Jira for backend authentication (like installation webhooks)
 * or web elements like iFrame
 *
 * Context tokens are tokens which are generated by App iframes for the authentication with app backend.
 * They don't require sqh verification and their qsh is set to a fixed `context-qsh` value.
 *
 * When building endpoints we should specify which type of tokens they require
 *
 * See details at: https://community.developer.atlassian.com/t/action-required-atlassian-connect-vulnerability-allows-bypass-of-app-qsh-verification-via-context-jwts/47072
 */ export enum TokenType { normal = "normal" , context = "context" } export function extractJwtFromRequest ( req ) { const tokenInQuery = req . query ?. [ JWT_PARAM ] ; const tokenInBody = req . body ?. [ JWT_PARAM ] ; if ( tokenInQuery && tokenInBody ) { req . log . info ( "JWT token can only appear in either query parameter or request body." ) ; return ; } let token = tokenInQuery || tokenInBody ; const authHeader = req . headers ?. [ AUTH_HEADER ] ; if ( authHeader ?. startsWith ( "JWT " ) ) { if ( token ) { const foundIn = tokenInQuery ? "query" : "request body" ; req . log . info ( `JWT token found in ${ foundIn } and in header: using ${ foundIn } value.` ) ; } else { token = authHeader . substring ( 4 ) ; } } if ( ! token ) { req . log . info ( "JWT token is missing in the request" ) ; } return token ; } export function sendError ( res , code , msg ) { res . status ( code ) . json ( { message : msg } ) ; } function decodeAsymmetricToken ( token , publicKey , noVerify ) { return decodeAsymmetric ( token , publicKey , getAlgorithm ( token ) , noVerify ) ; } function verifyQsh ( qsh , req ) { const requestInAtlassianJwtFormat = { ... req , pathname : req . path } ; let expectedHash = createQueryStringHash ( requestInAtlassianJwtFormat , false , BASE_URL ) ; let signatureHashVerified = qsh === expectedHash ; if ( ! signatureHashVerified ) { expectedHash = createQueryStringHash ( requestInAtlassianJwtFormat , true , BASE_URL ) ; signatureHashVerified = qsh === expectedHash ; if ( ! signatureHashVerified ) { return false ; } } return true ; } export function verifyJwtClaimsAndSetResponseCodeOnError ( verifiedClaims , tokenType , req , res ) { const expiry = verifiedClaims . exp ; if ( expiry && ( Date . now ( ) / 1000 >= expiry ) ) { req . log . info ( "JWT Verification Failed, token is expired" ) ; sendError ( res , 401 , "Authentication request has expired. Try reloading the page." ) ; return false ; } if ( verifiedClaims . qsh ) { let qshVerified = false ; if ( tokenType === TokenType . context ) { qshVerified = verifiedClaims . qsh === "context-qsh" ; } else { qshVerified = verifyQsh ( verifiedClaims . qsh , req ) ; } if ( ! qshVerified ) { req . log . info ( "JWT Verification Failed, wrong qsh" ) ; sendError ( res , 401 , "Unauthorized" ) ; } return qshVerified ; } else { req . log . info ( "JWT Verification Failed, no qsh" ) ; sendError ( res , 401 , "JWT tokens without qsh are not allowed" ) ; return false ; } } const verifySymmetricJwtAndSetResponseCodeOnError = ( secret , req , res , tokenType ) : boolean => { const token = extractJwtFromRequest ( req ) ; if ( ! token ) { sendError ( res , 401 , "Could not find authentication data on request" ) ; return false ; } const algorithm = getAlgorithm ( token ) ; let unverifiedClaims ; try { unverifiedClaims = decodeSymmetric ( token , "" , algorithm , true ) ; } catch ( e ) { sendError ( res , 401 , `Invalid JWT: ${ e . message }` ) ; return false ; } const issuer = unverifiedClaims . iss ; if ( ! issuer ) { sendError ( res , 401 , "JWT claim did not contain the issuer (iss) claim" ) ; return false ; } let verifiedClaims ; try { verifiedClaims = decodeSymmetric ( token , secret , algorithm , false ) ; } catch ( error ) { sendError ( res , 400 , `Unable to decode JWT token: ${ error }` ) ; return false ; } return verifyJwtClaimsAndSetResponseCodeOnError ( verifiedClaims , tokenType , req , res ) ; } ; param secret param tokenType param req param res param next export const verifySymmetricJwtTokenMiddleware = ( secret , tokenType , req , res , next ) : void => { try { if ( ! verifySymmetricJwtAndSetResponseCodeOnError ( secret , req , res , tokenType ) ) { return ; } req . log . info ( "JWT Token Verified Successfully!" ) ; next ( ) ; } catch ( error ) { req . log . error ( error , "Error happened when validating JWT token" ) ; sendError ( res , 401 , "Unauthorized" ) ; return ; } } ; const ALLOWED_BASE_URLS = [ BASE_URL ] ; const isStagingTenant = ( req ) : boolean => { try { const hostBaseUrl = req . body ?. baseUrl ; if ( hostBaseUrl ) { const host = new URL ( hostBaseUrl ) . hostname ; return /\.jira-dev\.com$/ . test ( host ) ; } } catch ( err ) { req . log . error ( err , "Error determining Jira instance environment" ) ; } return false ; } ; export const verifyAsymmetricJwtTokenMiddleware = async ( req , res , next ) : Promise < void > => { try { const token = extractJwtFromRequest ( req ) ; if ( ! token ) { req . log . info ( "JWT Verification Failed, no token present" ) ; sendError ( res , 401 , "Unauthorized" ) ; return ; } const publicKey = await queryAtlassianConnectPublicKey ( getKeyId ( token ) , isStagingTenant ( req ) ) ; const unverifiedClaims = decodeAsymmetricToken ( token , publicKey , true ) ; const issuer = unverifiedClaims . iss ; if ( ! issuer ) { req . log . info ( "JWT Verification Failed, no issuer present" ) ; sendError ( res , 401 , "JWT claim did not contain the issuer (iss) claim" ) ; return ; } if ( _ . isEmpty ( unverifiedClaims . aud ) || ! unverifiedClaims . aud [ 0 ] || ! _ . includes ( ALLOWED_BASE_URLS , unverifiedClaims . aud [ 0 ] . replace ( /\/$/ , "" ) ) ) { req . log . info ( "JWT Verification Failed, no correct audience present" ) ; sendError ( res , 401 , "WT claim did not contain the correct audience (aud) claim" ) ; return ; } const verifiedClaims = decodeAsymmetricToken ( token , publicKey , false ) ; if ( verifyJwtClaimsAndSetResponseCodeOnError ( verifiedClaims , TokenType . normal , req , res ) ) { req . log . info ( "JWT Token Verified Successfully!" ) ; next ( ) ; } } catch ( e ) { req . log . warn ( { ... e } , "Error while validating JWT token" ) ; sendError ( res , 401 , "Unauthorized" ) ; } } ;