SAML2 authentication in Axis2 using external STS - Part 2

Page content

This part is mostly technical and more a coding example with comments and a bit explanation.

Requirements

There are some requirements to be able to validate a SAML2 Token:

  1. You need a valid certificate from your authentication authority (Identity Provider).
  2. A keystore with this certificate (jks - there are a lot of well documented tools to generate one, eg. from Oracle).
  3. A secure way to store the keystore password. (Not included!)

If all these requrements are met, we start implementing the Token Handler.

Service and SOAP Header

Based on the Axis2 sample as described in the previous part the StockQuoteService simply returns a Double value via the getPrice method.

    public double getPrice(String symbol) {
        Double price = (Double) map.get(symbol);
        if (price != null) {
            return price.doubleValue();
        }
        return 42.00;
    }

Nothing magic.

To restrict access to this method via SAML2 token lets first have a look at the structure of the token and the SOAP request.

The SAML2 token is wrapped in the SOAP header:

<SOAP-ENV:Header xmlns:SOAP-ENV="http://schemas.xmlsoap.org/soap/envelope/">
    <Security
        xmlns="http://docs.oasis-open.org/wss/2004/01/oasis-200401-wss-wssecurity-secext-1.0.xsd">
        <saml2:EncryptedAssertion
            xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion"
            xmlns="" xmlns:ds="http://www.w3.org/2000/09/xmldsig#"
            xmlns:xenc="http://www.w3.org/2001/04/xmlenc#">
            <xenc:EncryptedData Type="http://www.w3.org/2001/04/xmlenc#Element">
                <xenc:EncryptionMethod Algorithm="http://www.w3.org/2001/04/xmlenc#aes256-cbc" />
                <ds:KeyInfo>
                    <xenc:EncryptedKey Recipient="CN=$YOUR_CN">
                        <xenc:EncryptionMethod Algorithm="http://www.w3.org/2001/04/xmlenc#rsa-1_5" />
                        <ds:KeyInfo>
                            <ds:X509Data>
                                <ds:X509Certificate>...</ds:X509Certificate>
                            </ds:X509Data>
                        </ds:KeyInfo>
                        <xenc:CipherData>
                            <xenc:CipherValue>....</xenc:CipherValue>
                        </xenc:CipherData>
                    </xenc:EncryptedKey>
                </ds:KeyInfo>
                <xenc:CipherData>
                    <xenc:CipherValue>...</xenc:CipherValue>
                </xenc:CipherData>
            </xenc:EncryptedData>
        </saml2:EncryptedAssertion>
    </Security>
</SOAP-ENV:Header>

Restricting access to the Service requires parsing this header and ensure the validity of the token transfered via the request.

The <xenc:EncryptedData> element contains a <xenc:EncryptionMethod> that describes the algorithm for token encrytion. The nested <ds:KeyInfo> element contains the <xenc:EncryptionMethod> for the symmetric key and another <ds:KeyInfo> element with the recipient key.

Validating the Token

To parse and process this SOAP header and the embedded SAML-Assertion we create a new class TokenHandler containing the XML namespaces, some XPATH expressions and information about the local service and the certificate. [1]

Processing the header consists of multiple steps of parsing, decryption, encryption and building XML documents.

The first step is to parse the given header and extract the encrypted part of the SAML-Assertion. This is achieved by building a namespace aware document element of the <Security> element. [2]

Now the resulting document can be decrypted. We need to load the keystore and get the contained private key to perform the decryption. [3]

To build a validation request the decrypted data has to be reencrypted using the public key contained in the signature of the header document and a generated symmetric key. [4]

The encrypted element can now be used to build the validation request. Beside the encrypted data the most important part is to put a set of attributes into the request document that describe the request. Required elements are the token type, request type and the validate target. [5]

To send the request we build a small HTTP-Client, set the appropriate request headers and post the previous build document to the security token server. The response document should be a small XML document contaning the elements <wst:RequestSecurityTokenResponse>, <wst:Status> and <wst:Code>. With the XPATH expression defined at the beginning we can extract the status code value and check if the token is valid. [6].

The last step is to use the TokenHandler in our StockQuoteService to authorize requests. [7]


We define some static information.

/**
 * Handler to validate the SAML2 token
 */
public class TokenHandler {

    // Static information about paths, certificate, authority.
    static String keystoreFile = "/PATH/TO/KEYSTORE/keystore.jks"; // Adjust path to keystore here
    static String passwordFile = "/PATH/TO/PASSWORD/my.properties"; // Plaintext password! Change this!
    static String certAlias = "YOUR ALIAS";
    static String endpointBaseUrl = "https://authority-endpoint.org/";
    static String operation = "Validate";
    static String msgUuid = java.util.UUID.randomUUID().toString();
    static String keystoreType = "JKS";

    // Namespaces used in SOAP request
    static String soap11NS = "http://schemas.xmlsoap.org/soap/envelope/";
    static String saml2NS = "urn:oasis:names:tc:SAML:2.0:assertion";
    static String xencNS = "http://www.w3.org/2001/04/xmlenc#";
    static String wsaNS = "http://www.w3.org/2005/08/addressing";
    static String wstNS = "http://docs.oasis-open.org/ws-sx/ws-trust/200512";
    static String dsNS = "http://www.w3.org/2000/09/xmldsig#";

    // XPATHs to extract information about validity in response.
    static String xpathValid =
        "//wst:RequestSecurityTokenResponse/wst:Status/wst:Code/text()";
    static String valid =
        "http://docs.oasis-open.org/ws-sx/ws-trust/200512/status/valid";

    [...]

Entrypoint of the token handler is the handleToken method that takes the SOAP header as parameter to extract and process the token via validateToken method.

    /**
     * Extract token from SOAPHeader and validate the token.
     *
     * @param header The SOAPHeader containing the token.
     *
     * @return True if the token is valid, false if invalid.
     */
    public static boolean handleHeader(SOAPHeader header) {
        try {
            return validateToken(header.getFirstElement());
        } catch (Exception e) {
            logger.fatal("Error validating token", e);
            return null;
        }
    }

    /**
     * Validates the security token.
     *
     * Decrypts the token using the private key for this service, then extracts
     * the embedded public key for signing and inserts the service certificate.
     * After encrytion, builds the validate request and sends it to the STS
     *
     * @param encryptedElem The encrypted header element
     *
     * @return True if the token is valid, false iif invalid.
     * @throws Exception
     */
    private static String validateToken(OMElement encryptedElem)
        throws Exception {

        /*
         * Parse header element.
         */
        Element encryptedDataElem = parseHeader(encryptedElem);
        Document docIn = encryptedDataElem.getOwnerDocument();

    [...]

To get the encrypted, namespace aware element we need to parse the header. This is achieved in a method called parseHeader.

    /**
     * Parse the SOAP header OMElement.
     *
     * @param element The OMElement extracted from SOAP request.
     *
     * @return The parsed element.
     * @throws RuntimeException
     */
    private static Element parseHeader(OMElement element) {
        javax.xml.parsers.DocumentBuilderFactory dbf =
            javax.xml.parsers.DocumentBuilderFactory.newInstance();
        dbf.setNamespaceAware(true);
        try {
            /* Decrypt security token */

            // Build serialized Output stream of encrypted element
            ByteArrayOutputStream baos = new ByteArrayOutputStream();
            element.serialize(baos);
            ByteArrayInputStream bais = new ByteArrayInputStream(baos.toByteArray());

            // Build a namespace aware document.
            javax.xml.parsers.DocumentBuilder docb = dbf.newDocumentBuilder();
            Element encryptedDataElem = docb.parse(bais).getDocumentElement();
            return encryptedDataElem;
        } catch (Throwable e) {
            throw new RuntimeException(
                "Error decrypting header element. Error: " + e.getMessage(), e);
        }
    }

Now that we have the encrypted element, we can load the keystore and decrypt the element using our own certificate.

    [...continue validateToken()]
        /*
         * Decrypt security token.
         */
        // Load password file
        InputStream pwis= new FileInputStream(passwordFile);
        Properties properties = new Properties();
        props.load(pwis);
        String keystorePass = props.getProperty("keystorePass");
        String privateKeyPass = props.getProperty("privateKeyPass");

        // Load keystore.
        KeyStore ks = KeyStore.getInstance(keystoreType);
        FileInputStream fis = new FileInputStream(keystoreFile);

        // Get the private key for the betreiber id.
        ks.load(fis, keystorePass.toCharArray());
        PrivateKey privateKey = (PrivateKey) ks.getKey(certAlias, privateKeyPass.toCharArray());

        // Decrypt the document.
        XMLCipher xmlCipher = XMLCipher.getInstance();
        xmlCipher.init(XMLCipher.DECRYPT_MODE, null);
        xmlCipher.setKEK(privateKey);
        xmlCipher.doFinal(encryptedDataElem.getOwnerDocument(), encryptedDataElem);
        Element decryptedSTElem =
            (Element) docIn.getElementsByTagNameNS(
                "urn:oasis:names:tc:SAML:2.0:assertion",
                "Assertion").item(0);
    [...]

The validation requires reencryption using the own certificate.

    [...continue validateToken()]
        /*
         * Reencrypt security token.
         */
        // Load keystore.
        ks = KeyStore.getInstance(keystoreType);
        fis = new FileInputStream(keystoreFile);

        // Get the service certificate.
        ks.load(fis, keystorePass.toCharArray());
        X509Certificate cert = (X509Certificate) ks.getCertificate(certAlias);

        // Add certificate to encrypted key element.
        KeyInfo certKeyInfo = new KeyInfo(docIn);
        X509Data x509Data = new X509Data(docIn);
        x509Data.addCertificate(cert);
        certKeyInfo.add(x509Data);

        // extract the public key from signature.
        PublicKey pKey = extractPublicKey(docIn);

        // Generate symmetric key for encrytion.
        KeyGenerator keyGenerator = KeyGenerator.getInstance("AES");
        keyGenerator.init(256);
        Key symmetricKey = keyGenerator.generateKey();

        // Encrypt the document.
        encryptedDataElem = encryptDocument(docIn, decryptedSTElem, pKey, symmetricKey, cert, certKeyInfo);
    [...]

Extracting the public key is done in a little helper method:

    /**
     * Extracts the public key from the incomming decryted document.
     *
     * @param doc The decrypted document.
     *
     * @return The public key embedded in the signature.
     * @throws RuntimeException
     */
    private static PublicKey extractPublicKey(Document doc) {
        Element n = (Element)doc.getElementsByTagNameNS(
            "http://www.w3.org/2000/09/xmldsig#", "Signature").item(0);
        try {
            XMLSignature sig = new XMLSignature(n, "");
            KeyInfo ki = sig.getKeyInfo();
            X509Data xData = ki.itemX509Data(0);
            String xCert = xData.itemCertificate(0).getTextFromTextChild();
            byte[] publicBytes = Base64.decode(xCert);
            CertificateFactory certFactory =
                CertificateFactory.getInstance("X.509");
            InputStream pkStream = new ByteArrayInputStream(publicBytes);
            Certificate certificate = certFactory.generateCertificate(pkStream);
            return certificate.getPublicKey();
        } catch (Throwable e) {
            throw new RuntimeException(
                "Error reading signature. Error: " + e.getMessage(), e);
        }
    }

Encrypt the document.

    /**
     * Encrypt the document using the given parameter.
     *
     * @param doc The owner document.
     * @param element The element to encrypt.
     * @param pKey The public key used to encrypt.
     * @param symmetricKey The key used to initialize the encrytion.
     * @param cert The service certificate.
     * @param keyInfo KeyInfo element inserted in the document.
     *
     * @return The encrypted element.
     * @throws RuntimeException
     */
    private static Element encryptDocument(
        Document doc,
        Element element,
        PublicKey pKey,
        Key symmetricKey,
        X509Certificate cert,
        KeyInfo keyInfo
    ) {
        try {
            XMLCipher keyCipher = XMLCipher.getInstance(XMLCipher.RSA_v1dot5);
            keyCipher.init(XMLCipher.WRAP_MODE, pKey);
            EncryptedKey encryptedKey = keyCipher.encryptKey(doc, symmetricKey);
            encryptedKey.setRecipient(cert.getSubjectX500Principal().getName());
            encryptedKey.setKeyInfo(keyInfo);

            XMLCipher xmlCipher = XMLCipher.getInstance(XMLCipher.AES_256);
            xmlCipher.init(XMLCipher.ENCRYPT_MODE, symmetricKey);

            EncryptedData encryptedData = xmlCipher.getEncryptedData();
            KeyInfo ekKeyInfo = new KeyInfo(doc);
            ekKeyInfo.getElement().setAttributeNS(
                Constants.NamespaceSpecNS, "xmlns:ds", dsNS);
            ekKeyInfo.add(encryptedKey);
            encryptedData.setKeyInfo(ekKeyInfo);

            xmlCipher.doFinal(doc, element);

            // Get the encrypted element from document
            return (Element) doc.getElementsByTagNameNS(
                EncryptionConstants.EncryptionSpecNS,
                EncryptionConstants._TAG_ENCRYPTEDDATA).item(0);
        } catch (Throwable e) {
            throw new RuntimeException(
                "Error encrypting document. Error: " + e.getMessage(), e);
        }
    }

Now we build the validation request. There are many nodes and attributes to set!

    [...continue validateToken()]
        /*
         * Build Validate request.
         */
        // Create a document factory.
        javax.xml.parsers.DocumentBuilderFactory dbf =
            javax.xml.parsers.DocumentBuilderFactory.newInstance();
        dbf.setNamespaceAware(true);
        javax.xml.parsers.DocumentBuilder db = dbf.newDocumentBuilder();
        org.w3c.dom.Document docOut = db.newDocument();
        Node encryptedSTElem = docOut.importNode((Node) encryptedDataElem, true);

        // Create surrounding header elements
        Element envelopeElement = docOut.createElementNS(soap11NS, "soap11:Envelope");
        Element headerElem = docOut.createElementNS(soap11NS, "soap11:Header");
        Element wsaToElem = docOut.createElementNS(wsaNS, "wsa:To");
        Element wsaActionElem = docOut.createElementNS(wsaNS, "wsa:Action");
        Element wsaMessageIdElem = docOut.createElementNS(wsaNS, "wsa:MessageID");
        Element bodyElem = docOut.createElementNS(soap11NS, "soap11:Body");
        Element rstElem = docOut.createElementNS(wstNS, "wst:RequestSecurityToken");
        Element ttElem = docOut.createElementNS(wstNS, "wst:TokenType");
        Element rtElem = docOut.createElementNS(wstNS, "wst:RequestType");
        Element vtElem = docOut.createElementNS(wstNS, "wst:ValidateTarget");
        Element eaElem = docOut.createElementNS(saml2NS, "saml2:EncryptedAssertion");

        docOut.appendChild(envelopeElement);

        // <soap11:Envelope xmlns:soap11="http://schemas.xmlsoap.org/soap/envelope/">
        envelopeElement.setAttributeNS(Constants.NamespaceSpecNS, "xmlns:soap11", soap11NS);
        envelopeElement.appendChild(docOut.createTextNode("\n  "));
        envelopeElement.appendChild(headerElem);
        envelopeElement.appendChild(docOut.createTextNode("\n  "));
        envelopeElement.appendChild(bodyElem);
        envelopeElement.appendChild(docOut.createTextNode("\n"));

        // <soap11:Header>
        headerElem.setAttributeNS(Constants.NamespaceSpecNS, "xmlns:wsa", wsaNS);
        headerElem.appendChild(docOut.createTextNode("\n    "));
        headerElem.appendChild(wsaToElem);
        headerElem.appendChild(docOut.createTextNode("\n    "));
        headerElem.appendChild(wsaActionElem);
        headerElem.appendChild(docOut.createTextNode("\n    "));
        headerElem.appendChild(wsaMessageIdElem);
        headerElem.appendChild(docOut.createTextNode("\n  "));

        // <wsa:*>
        wsaToElem.appendChild(docOut.createTextNode(endpointBaseUrl+"/RST/"+operation));
        wsaActionElem.appendChild(docOut.createTextNode("http://docs.oasis-open.org/ws-sx/ws-trust/200512/RST/"+operation));
        wsaMessageIdElem.appendChild(docOut.createTextNode("uuid:"+msgUuid));

        // <soap11:Body>
        bodyElem.appendChild(docOut.createTextNode("\n    "));
        bodyElem.appendChild(rstElem);
        bodyElem.appendChild(docOut.createTextNode("\n  "));

        // <wst:RequestSecurityToken Context="2de00929-775b-4069-ade6-d568087a1920" xmlns:wst="http://docs.oasis-open.org/ws-sx/ws-trust/200512">
        rstElem.setAttributeNS(Constants.NamespaceSpecNS, "xmlns:wst", wstNS);
        rstElem.setAttributeNS(wstNS, "Context", java.util.UUID.randomUUID().toString());
        rstElem.appendChild(docOut.createTextNode("\n      "));
        rstElem.appendChild(ttElem);
        rstElem.appendChild(docOut.createTextNode("\n      "));
        rstElem.appendChild(rtElem);
        rstElem.appendChild(docOut.createTextNode("\n      "));
        rstElem.appendChild(vtElem);
        rstElem.appendChild(docOut.createTextNode("\n    "));

        // <wst:TokenType>http://docs.oasis-open.org/ws-sx/ws-trust/200512/RSTR/Status</wst:TokenType>
        ttElem.appendChild(docOut.createTextNode("http://docs.oasis-open.org/ws-sx/ws-trust/200512/RSTR/Status"));

        // <wst:RequestType>http://docs.oasis-open.org/ws-sx/ws-trust/200512/Validate</wst:RequestType>
        rtElem.appendChild(docOut.createTextNode("http://docs.oasis-open.org/ws-sx/ws-trust/200512/Validate"));

        // <wst:ValidateTarget>
        vtElem.appendChild(docOut.createTextNode("\n        "));
        vtElem.appendChild(eaElem);
        vtElem.appendChild(docOut.createTextNode("\n      "));

        // <saml2:EncryptedAssertion xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion">
        eaElem.setAttributeNS(Constants.NamespaceSpecNS, "xmlns:saml2", saml2NS);
        eaElem.appendChild(docOut.createTextNode("\n          "));
        eaElem.appendChild(encryptedSTElem);
        eaElem.appendChild(docOut.createTextNode("\n        "));
    [...]

Now we can send the request and check the response.

    [...continue validateToken(]
        /*
         * Send validate request
         */
        Document response = request(docToString(docOut));
        if (response == null) {
            return false;
        }

        /*
         * Check if token is valid
         */
        // Create namespace context for xpath.
        NamespaceContext ctx = new NamespaceContext() {
            public String getNamespaceURI(String prefix) {
                return prefix.equals("wst") ? "http://docs.oasis-open.org/ws-sx/ws-trust/200512" : null;
            }
            public Iterator getPrefixes(String val) {
                return null;
            }
            public String getPrefix(String uri) {
                return null;
            }
        };

        // Use xpath to extract the token status value.
        XPathFactory xPathfactory = XPathFactory.newInstance();
        XPath xpath = xPathfactory.newXPath();
        xpath.setNamespaceContext(ctx);
        XPathExpression expr = xpath.compile(xpathValid);
        String valid = expr.evaluate(response);

        // Check if status equals the valid value
        if (valid != null && valid.equals(istsValid)) {
            return true;
        }
        logger.fatal("Error! ISTS response:");
        logger.fatal(docToString(response));
        return false;

We have to build a small HTTP-Client to issue the request.

    /**
     * Send the validate request using the prebuild content.
     *
     * @param content The validate SOAP content.
     *
     * @return The document parsed from response.
     */
    private static Document request(String content) {
        // Build a http client.
        CloseableHttpClient httpClient = HttpClientBuilder.create().build();
        HttpPost post = new HttpPost(endpointBaseUrl + "/RST/Validate");
        // Set header for SOAP request.
        post.setHeader("SOAPAction", "Validate");
        post.setHeader("Content-Type", "text/xml;charset=UTF-8");
        try {
            // Send as POST using the content as payload.
            post.setEntity(new StringEntity(content));
            CloseableHttpResponse response = httpClient.execute(post);
            // Parse the response and build a document
            javax.xml.parsers.DocumentBuilderFactory dbf = javax.xml.parsers.DocumentBuilderFactory.newInstance();
            dbf.setNamespaceAware(true);
            javax.xml.parsers.DocumentBuilder db = dbf.newDocumentBuilder();
            return db.parse(response.getEntity().getContent());
        } catch (IOException |
            ParserConfigurationException |
            SAXException e) {
            logger.fatal("Response from authorization service is no valid XML.", e);
        }
        return null;
    }

The TokenHandler can now validate a Token issued as SOAP request so we can use it in our getPrice method to authorize the request.

The only thing is to extract the SOAP Header and call the TokenHandler.

    public double getPrice(String symbol) {
        // The MessageContext to get the SOAP header
        MessageContext ctx = MessageContext.getCurrentMessageContext();
        // Extract the header
        SOAPHeader header = ctx.getEnvelope().getHeader();
        String authorized = null;
        // No header found. :( We are not authorized.
        if(null == header) {
            logger.fatal("Not authorized - No valid SAML2 header");
            throw new RuntimeException("Not authorized - No valid SAML2 header");
        }
        else {
            try {
                authorized = TokenHandler.handleHeader(header);
            }
            catch(Exception e) {
                throw new RuntimeException(e.getMessage());
            }
        }
        if (!authorized) {
            throw new RuntimeException("Not authorized - Token not valid");
        }

        // YEAH we are authorized and get the price!
        Double price = (Double) map.get(symbol);
        if (price != null) {
            return price.doubleValue();
        }
        return 42.00;
    }