#!/bin/bash
#
# Requires bash version >= 4.
#
# This simple client uses command line tools to
# demonstrate how a SAML ECP client works.
#
# Studying this client is not an acceptable replacement
# for reading Draft 02 of the ECP profile [ECP] available at
#
# http://wiki.oasis-open.org/security/SAML2EnhancedClientProfile
#
# Please read the profile document and consult this script
# as one example of a non-conformant client.
# This script cannot be considered a conformant client as defined
# in section 3.1.3 of [ECP] because it does not support the use of
# channel bindings of type "tls-server-end-point" nor does it support
# TLS Client Authentication.
#
# This client has been tested on Debian Squeeze against
# the Shibboleth IdP version 2.2.1 with the
# IdP ECP extension detailed at
#
# https://wiki.shibboleth.net/confluence/display/SHIB2/IdP+ECP+Extension
#
# and the Shibboleth Native SP version 2.4.2.
#
# The script assumes the ECP extension is installed and properly
# configured for the IdP and that the SP is configured
# properly with ECP support. See the Shibboleth documentation for
# details.
#
# It also assumes that the server hosting the IdP has been properly
# configured to require a type of Basic Auth (login and password)
# for the ECP location. See the documentation for the IdP ECP extension
# for details.
#
# The script uses the command line tool 'curl' for querying the SP
# and IdP. It uses the command line tool 'xsltproc' for
# simple parsing and manipulation of XML. Consult a reference
# on XSLT and XPath for how to craft the stylesheet inputs to
# xsltproc. A better programmer could probably make sed and grep
# do the same thing.


# hash array of tags the user can use on the command
# line that map to IdP SAML2 ECP endpoints

# Setup environment
unset LD_LIBRARY_PATH
unset DYLD_LIBRARY_PATH
unset LD_PRELOAD
umask 077

# curl is required for sending to and from the SP and IdP
# xlstproc is required for gently massaging XML
# klist is required to check for valid kerberos ticket
# tempfile or mktemp is required for safe temporary files

type -P /usr/bin/curl >&/dev/null || { echo "This script requires curl. Aborting." >&2; exit 1; }
type -P /usr/bin/xsltproc >&/dev/null || { echo "This script requires xsltproc. Aborting." >&2; exit 1;}

curl_command=/usr/bin/curl
xsltproc_command=/usr/bin/xsltproc
klist_command=/usr/bin/klist

VERSION="1.3.6"

usage()
{
cat << EOF
usage: `basename $0` [options] [IdP_tag] target_url [login]

OPTIONS:
    -h                Show this message
    -d                Write debug output to stdout
    -v                Print version information
    -i hostname       Use alternative IdP host e.g. login2.ligo.org
    -k                Enable Kerberos authentication
    -K                Force Kerberos authentication without checking klist
    -c cookiefile     Use specifed cookie file
    -o outfile        Direct target to file
    -n                Do not output target
    -q                Quiet mode
    -X                Destroy cookie file

If using Kerberos authentication do not include the IdP_tag or login arguments.

EXAMPLE:

`basename $0` Campus01 https://campus01.edu/my/secret/page jsmith
`basename $0` -k https://campus01.edu/my/secret/page

CONFIGURED IDP TAGS:
  LIGO.ORG            LIGO.ORG
  LIGOGuest           LIGO Guest Services
  SUGWG               Syracuse University Gravitational Wave Group
  CardiffUniversity   Cardiff University
EOF
}

version()
{
  cat <<EOF
`basename $0` version $VERSION
EOF

  echo
  $curl_command --version
  echo
  $xsltproc_command -version
  echo
  uname -a
  echo

  if [ -e /etc/issue ]
  then
      cat /etc/issue
  fi
}

destroy()
{
    if [ -e $cookie_file ] ; then
	check_cookiefile

	rm -f $cookie_file
	ret=$?
	if [ $ret -ne 0 ] ; then
	    echo "Not able to destroy cookie file $cookie_file"
	    exit 1
	fi
    fi
}

function check_cookiefile() {
    if [ ! -f $cookie_file ]; then
	echo "ERROR: cookie file $cookie_file is not a regular file"
	exit 1
    fi

    cookie_file_mode=$(/usr/bin/python -c "import os, stat; print(os.stat(\"${cookie_file}\")[stat.ST_MODE])")
    cookie_file_uid=$(/usr/bin/python -c "import os, stat; print(os.stat(\"${cookie_file}\")[stat.ST_UID])")

    if [ $cookie_file_uid -ne $(id -u) ]; then
	echo "ERROR: cookie file $cookie_file not owned by $(whoami)"
	exit 1
    fi
    if [ $cookie_file_mode != "33152" ]; then
	echo "ERROR: cookie file $cookie_file permissions are incorrect"
	exit 1
    fi
}

function logging(){ echo $@; }
DEBUG=
VERBOSE="--silent"
OUTFILE=/dev/null
ERRFILE=/dev/null
OUTPUT=/dev/stdout
cookie_file=/tmp/ecpcookie.u`id -u`

while getopts ":hdkKvc:no:Xi:q" OPTION
do
    case $OPTION in
	h)
	  usage
	  exit 0
	  ;;
	d)
	  DEBUG=1
	  VERBOSE="--verbose"
	  OUTFILE=/dev/stdout
	  ERRFILE=/dev/stderr
	  ;;
	k)
	 ECPCOOKIEINIT_USE_KERBEROS=1
	  ;;
	K)
	 ECPCOOKIEINIT_USE_KERBEROS=2
	  ;;
	v)
	  version
	  exit 0
	  ;;
	i)
	  idp_hosts=${idp_hosts}" "${OPTARG}
	  ;;
	c)
	  cookie_file=${OPTARG}
	  ;;
	o)
	  OUTPUT=${OPTARG}
	  ;;
	n)
	  OUTPUT=/dev/null
	  ;;
	q)
	  logging(){ :; }
	  ;;
	X)
	  destroy
	  exit 0
	  ;;
	:)
	  echo "Option $OPTARG requires an argument." >&2
	  exit 1
	  ;;
    esac
done

shift $((OPTIND - 1))

if [ -n "${ECPCOOKIEINIT_USE_KERBEROS}" ] ; then
    $curl_command -V | grep -Eq "(GSS-Negotiate|SPNEGO)"
    if [ $? -ne 0 ]; then
	echo "Kerberos authentication requires a curl library built with GSS-API and SPNEGO support."
	echo "To use password authentication please remove the -k option and provide IdP tag and login"
	exit 1
    fi

    if [ $# -ne 1 ] ; then
	usage
	exit 1
    fi

    if [ "${ECPCOOKIEINIT_USE_KERBEROS}" -ne "2" ] ; then
	$klist_command -s 2> $ERRFILE
	ret=$?
	if [ $ret -ne 0 ] ; then
	    echo "klist command failed. Please ensure you have a valid Kerberos ticket"
	    echo "or use password authentication."
	    echo
	    echo "Return value was $ret."
	    echo
	    echo "Email rt-auth@ligo.org with the output above for help."
	    exit 1
	fi
    fi

    klist_output=$($klist_command 2> $ERRFILE)

    if [ -n "$DEBUG" ]
    then
	echo
	echo "###### BEGIN KLIST OUTPUT"
	echo
	echo "$klist_output"
	echo
	echo "###### END KLIST OUTPUT"
	echo
    fi

    target=$1

    principal=$(echo "$klist_output" | grep -im 1 "principal" | awk '{print $NF}')
    idp_tag=$(echo $principal | awk -F '@' '{ print $2 }' )
    logging "Using identity $principal"

    curl_auth_method="--negotiate --user :"
else
    if [ $# -ne 3 ] ; then
	usage
	exit 1
    fi

    idp_tag=$1
    target=$2
    login=$3

    curl_auth_method="--user $login"
fi

if [ "${idp_tag}" = "LIGO.ORG" ] ; then
    : ${idp_hosts:="login.ligo.org login2.ligo.org"}
elif [ "${idp_tag}" = "LIGOGuest" ] ; then
    : ${idp_hosts:="login.guest.ligo.org"}
elif [ "${idp_tag}" = "SUGWG" ] ; then
    : ${idp_hosts:="sugwg-login.phy.syr.edu"}
elif [ "${idp_tag}" = "CardiffUniversity" ] ; then
    : ${idp_hosts:="idp.cf.ac.uk"}
elif [ "${idp_tag}" = "TEST.LIGO.ORG" ] ; then
    : ${idp_hosts:="login-test.ligo.org"}
elif [ "${idp_tag}" = "DEV.LIGO.ORG" ] ; then
    : ${idp_hosts:="login-dev.ligo.org"}
else
    echo "Error: unknown IDP endpoint '${idp_tag}'"
    if [ -n "${ECPCOOKIEINIT_USE_KERBEROS}" ] ; then
	echo "Please check klist output:";
	echo
	klist
    fi
    exit 1
fi

if [ -n "$DEBUG" ]; then
    version;
    echo
    echo "###### BEGIN COMPUTED OPTIONS"
    echo
    echo IdP Hosts: $idp_hosts
    echo Cookie File: $cookie_file
    echo
    echo "###### END COMPUTED OPTIONS"
    echo
fi

connect_timeout=20
max_time=45

# either tempfile or mktemp is required for creating and managing temp files
temp_file_command=`type -P tempfile`
if [ ! $temp_file_command ] ; then
    temp_file_command=`type -P mktemp`
    if [ ! $temp_file_command ] ; then
	echo "This script requires tempfile or mktemp. Aborting." >&2
	exit 1
    else
	temp_file_maker="$temp_file_command /tmp/ligo_proxy_init.XXXXXX"
    fi
else
    temp_file_maker=$temp_file_command
fi

# verify that the target is of the form https://
if [[ ! "$target" =~ ^https:// ]]
then
    echo "Target is not of the form https://..."
    exit 1
fi

# some utility functionality for deleting temporary files
declare -a on_exit_items

function on_exit()
{
    for i in "${on_exit_items[@]}"
    do
	eval $i
    done
}

function add_on_exit()
{
    local n=${#on_exit_items[*]}
    on_exit_items[$n]="$*"
    if [[ $n -eq 0 ]]; then
	trap on_exit EXIT
    fi
}

# create a file curl can use to save session cookies
touch ${cookie_file}
chmod 600 ${cookie_file}

# make sure everything's in order and no one is trying any shennanigans
#
# NOTE: we use python one-liners here instead of /usr/bin/stat
# because stat's syntax isn't portable between Linux and MacOS

check_cookiefile

# headers needed for ECP
header_accept="Accept:text/html; application/vnd.paos+xml"
header_paos="PAOS:ver=\"urn:liberty:paos:2003-08\";\"urn:oasis:names:tc:SAML:2.0:profiles:SSO:ecp\""

# request the target from the SP and include headers signalling ECP
sp_resp=`$curl_command $VERBOSE -j -c $cookie_file -b $cookie_file -H "$header_accept" -H "$header_paos" "$target"`

ret=$?
if [ $ret -ne 0 ]
then
    echo "First curl GET of $target failed."
    echo "Return value was $ret."
    echo
    echo "Email rt-auth@ligo.org with the output above for help."
    exit 1
fi

if [ -n "$DEBUG" ]
then
    echo
    echo "###### BEGIN SP RESPONSE"
    echo
    echo $sp_resp
    echo
    echo "###### END SP RESPONSE"
    echo
fi

# craft the request to the IdP by using xsltproc
# and a stylesheet to remove the SOAP header
# but leave everything else

stylesheet_remove_header=`$temp_file_maker`
add_on_exit rm -f $stylesheet_remove_header

cat >> $stylesheet_remove_header <<EOF
<xsl:stylesheet version="1.0"
 xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
 xmlns:S="http://schemas.xmlsoap.org/soap/envelope/" >

 <xsl:output omit-xml-declaration="yes"/>

    <xsl:template match="node()|@*">
      <xsl:copy>
	 <xsl:apply-templates select="node()|@*"/>
      </xsl:copy>
    </xsl:template>

    <xsl:template match="S:Header" />

</xsl:stylesheet>
EOF

idp_request=`echo "$sp_resp" | $xsltproc_command $stylesheet_remove_header - 2> $ERRFILE`

ret=$?

if [ $ret -ne 0 ]
then
    echo "Parse error from xsltproc on first curl GET of $target."
    echo "Return value was $ret."
    echo
    echo "You most likely entered the URL incorrectly. If this error persists please"
    echo "email rt-auth@ligo.org with the output above for help."
    exit 1
fi

if [ -n "$DEBUG" ]
then
    echo
    echo "###### BEGIN IDP REQUEST"
    echo
    echo $idp_request
    echo
    echo "###### END IDP REQUEST"
    echo
fi

# pick out the relay state element from the SP response
# so that it can later be included in the package to the SP

stylesheet_get_relay_state=`$temp_file_maker`
add_on_exit rm -f $stylesheet_get_relay_state

cat >> $stylesheet_get_relay_state <<EOF
<xsl:stylesheet version="1.0"
 xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
 xmlns:ecp="urn:oasis:names:tc:SAML:2.0:profiles:SSO:ecp"
 xmlns:S="http://schemas.xmlsoap.org/soap/envelope/" >

 <xsl:output omit-xml-declaration="yes"/>

 <xsl:template match="/">
     <xsl:copy-of select="//ecp:RelayState" />
 </xsl:template>

</xsl:stylesheet>
EOF

relay_state=`echo "$sp_resp" | $xsltproc_command $stylesheet_get_relay_state -`

ret=$?
if [ $ret -ne 0 ]
then
    echo "Parse error from xsltproc for relay state element."
    echo "Return value was $ret."
    echo
    echo "Email rt-auth@ligo.org with the output above for help."
    exit 1
fi

if [ -n "$DEBUG" ]
then
    echo
    echo "###### BEGIN RELAY STATE ELEMENT"
    echo
    echo $relay_state
    echo
    echo "###### END RELAY STATE ELEMENT"
    echo
fi

# pick out the responseConsumerURL attribute value from the SP response
# so that it can later be compared to the assertionConsumerURL sent from
# the IdP

stylesheet_get_responseConsumerURL=`$temp_file_maker`
add_on_exit rm -f $stylesheet_get_responseConsumerURL

cat >> $stylesheet_get_responseConsumerURL <<EOF
<xsl:stylesheet version="1.0"
 xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
 xmlns:ecp="urn:oasis:names:tc:SAML:2.0:profiles:SSO:ecp"
 xmlns:S="http://schemas.xmlsoap.org/soap/envelope/"
 xmlns:paos="urn:liberty:paos:2003-08" >

 <xsl:output omit-xml-declaration="yes"/>

 <xsl:template match="/">
     <xsl:value-of select="/S:Envelope/S:Header/paos:Request/@responseConsumerURL" />
 </xsl:template>

</xsl:stylesheet>
EOF

responseConsumerURL=`echo "$sp_resp" | $xsltproc_command $stylesheet_get_responseConsumerURL -`

ret=$?
if [ $ret -ne 0 ]
then
    echo "Parse error from xsltproc for consumer URL."
    echo "Return value was $ret."
    echo "Use -d to see full SP response."
    exit 1
fi

if [ -n "$DEBUG" ]
then
    echo
    echo "###### BEGIN RESPONSE CONSUMER URL"
    echo
    echo $responseConsumerURL
    echo
    echo "###### END RESPONSE CONSUMER URL"
    echo
fi

for idp_host in $idp_hosts; do
    idp_endpoint=https://${idp_host}/idp/profile/SAML2/SOAP/ECP

    if [ -n "$DEBUG" ] || [ -n "$report_idp" ]; then
	logging "Attempting connection to $idp_host"
    fi

    # use curl to POST the request to the IdP the user signalled on the command line
    # and use the login supplied by the user, prompting for a password
    idp_response=`$curl_command $VERBOSE --fail --connect-timeout $connect_timeout -m $max_time -X POST -H 'Content-Type: text/xml; charset=utf-8' -c $cookie_file -b $cookie_file $curl_auth_method -d "$idp_request" $idp_endpoint`

    ret=$?
    if [ $ret -eq 0 ] ; then break; fi

    echo
    echo "curl POST to IdP at endpoint $idp_endpoint failed. Error code ${ret}"
    echo

    if [ ! -n "${ECPCOOKIEINIT_USE_KERBEROS}" ]; then
	echo "You most likely incorrectly entered your passphrase."
	echo
    else
	echo "Please ensure that you have a valid Kerberos ticket"
	echo
    fi

    report_idp=true
done

if [ $ret -ne 0 ]
then
    echo "If this error persists please email rt-auth@ligo.org"
    echo "with the output above for help."
    echo
    exit 1
fi

if [ -n "$DEBUG" ]
then
    echo
    echo "###### BEGIN IDP RESPONSE"
    echo
    echo $idp_response
    echo
    echo "###### END IDP RESPONSE"
    echo
fi

# use xlstproc to pick out the assertion consumer service URL
# from the response sent by the IdP

stylesheet_assertion_consumer_service_url=`$temp_file_maker`
add_on_exit rm -f $stylesheet_assertion_consumer_service_url

cat >> $stylesheet_assertion_consumer_service_url <<EOF
<xsl:stylesheet version="1.0"
 xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
 xmlns:ecp="urn:oasis:names:tc:SAML:2.0:profiles:SSO:ecp"
 xmlns:S="http://schemas.xmlsoap.org/soap/envelope/" >

 <xsl:output omit-xml-declaration="yes"/>

 <xsl:template match="/">
     <xsl:value-of select="S:Envelope/S:Header/ecp:Response/@AssertionConsumerServiceURL" />
 </xsl:template>

</xsl:stylesheet>
EOF

assertionConsumerServiceURL=`echo "$idp_response" | $xsltproc_command $stylesheet_assertion_consumer_service_url -`

ret=$?
if [ $ret -ne 0 ]
then
    echo "Parse error from xsltproc for ACS URL."
    echo "Return value was $ret."
    echo "Use -d to see full IDP response."
    exit 1
fi

if [ -n "$DEBUG" ]
then
    echo
    echo "###### BEGIN ASSERTION CONSUMER SERVICE URL"
    echo
    echo $assertionConsumerServiceURL
    echo
    echo "###### END ASSERTION CONSUMER SERVICE URL"
    echo
fi

# compare the responseConsumerURL from the SP to the
# assertionConsumerServiceURL from the IdP and if they
# are not identical then send a SOAP fault to the SP

if [ "$responseConsumerURL" != "$assertionConsumerServiceURL" ]
then

echo "ERROR: assertionConsumerServiceURL $assertionConsumerServiceURL does not"
echo "match responseConsumerURL $responseConsumerURL"
echo ""
echo "sending SOAP fault to SP"

read -d '' soap_fault <<"EOF"
<S:Envelope xmlns:S="http://schemas.xmlsoap.org/soap/envelope/">
   <S:Body>
     <S:Fault>
       <faultcode>S:Server</faultcode>
       <faultstring>responseConsumerURL from SP and assertionConsumerServiceURL from IdP do not match</faultstring>
     </S:Fault>
   </S:Body>
</S:Envelope>
EOF

$curl_command $VERBOSE -X POST -c $cookie_file -b $cookie_file -d "$soap_fault" -H "Content-Type: application/vnd.paos+xml" $responseConsumerURL 1> $OUTFILE 2> $ERRFILE

exit 1

fi

# craft the package to send to the SP by
# copying the response from the IdP but removing the SOAP header
# sent by the IdP and instead putting in a new header that
# includes the relay state sent by the SP

stylesheet_sp_package=`$temp_file_maker`
add_on_exit rm -f $stylesheet_sp_package

cat >> $stylesheet_sp_package <<EOF
<xsl:stylesheet version="1.0"
 xmlns:xsl="http://www.w3.org/1999/XSL/Transform"
 xmlns:soap11="http://schemas.xmlsoap.org/soap/envelope/" >

 <xsl:output omit-xml-declaration="no" encoding="UTF-8"/>

    <xsl:template match="node()|@*">
      <xsl:copy>
	 <xsl:apply-templates select="node()|@*"/>
      </xsl:copy>
    </xsl:template>

    <xsl:template match="soap11:Header" >
	<soap11:Header>$relay_state</soap11:Header>
    </xsl:template>

</xsl:stylesheet>
EOF

sp_package=`echo "$idp_response" | $xsltproc_command $stylesheet_sp_package -`

ret=$?
if [ $ret -ne 0 ]
then
    echo "Parse error from xsltproc for SP package."
    echo "Return value was $ret."
    echo "Use -d to see full IDP response."
    exit 1
fi

if [ -n "$DEBUG" ]
then
    echo
    echo "###### BEGIN PACKAGE TO SEND TO SP"
    echo
    echo $sp_package
    echo
    echo "###### END PACKAGE TO SEND TO SP"
    echo
fi

# push the response to the SP at the assertion consumer service
# URL included in the response from the IdP

$curl_command $VERBOSE -c $cookie_file -b $cookie_file -X POST -d "$sp_package" -H "Content-Type: application/vnd.paos+xml" $assertionConsumerServiceURL 1> $OUTFILE 2> $ERRFILE

ret=$?
if [ $ret -ne 0 ]
then
    echo "Second curl POST to SP failed."
    echo "Return value was $ret."
    exit 1
fi

# use curl and the existing established session to get the original target
$curl_command $VERBOSE -c $cookie_file -b $cookie_file -X GET "$target" > $OUTPUT 2> $ERRFILE

# on exit the temporary files and cookies will be deleted
# a more sophisticated client could save the cookies and make
# them available for further requests from the same SP

exit 0
