Introspect.java

/*******************************************************************************
 * Copyright (c) 2019, RISE AB
 * All rights reserved.
 *
 * Redistribution and use in source and binary forms, with or without 
 * modification, are permitted provided that the following conditions 
 * are met:
 *
 * 1. Redistributions of source code must retain the above copyright notice, 
 *    this list of conditions and the following disclaimer.
 *
 * 2. Redistributions in binary form must reproduce the above copyright notice, 
 *    this list of conditions and the following disclaimer in the documentation 
 *    and/or other materials provided with the distribution.
 *
 * 3. Neither the name of the copyright holder nor the names of its
 *    contributors may be used to endorse or promote products derived from
 *    this software without specific prior written permission.
 *
 * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS 
 * "AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT 
 * LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR 
 * A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT 
 * HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, 
 * SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT 
 * LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, 
 * DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY 
 * THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT 
 * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE 
 * OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
 *******************************************************************************/
package se.sics.ace.as;

import java.util.HashSet;
import java.util.Map;
import java.util.Set;
import java.util.logging.Level;
import java.util.logging.Logger;

import com.upokecenter.cbor.CBORObject;
import com.upokecenter.cbor.CBORType;

import org.eclipse.californium.cose.HeaderKeys;
import org.eclipse.californium.cose.OneKey;

import se.sics.ace.AccessToken;
import se.sics.ace.AceException;
import se.sics.ace.Constants;
import se.sics.ace.Endpoint;
import se.sics.ace.Message;
import se.sics.ace.ReferenceToken;
import se.sics.ace.TimeProvider;
import se.sics.ace.cwt.CWT;
import se.sics.ace.cwt.CwtCryptoCtx;

/**
 * The OAuth 2.0 Introspection endpoint.
 * @author Ludwig Seitz and Marco Tiloca
 *
 */
public class Introspect implements Endpoint, AutoCloseable {
    
    /**
     * The logger
     */
    private static final Logger LOGGER 
        = Logger.getLogger(Introspect.class.getName() );

    /**
     * Boolean for verify
     */
    private static boolean verify = true;
    
    /**
     * The PDP this endpoint uses to make access control decisions.
     */
    private PDP pdp;
    
    /**
     * The database connector for storing and retrieving stuff.
     */
    private DBConnector db;
    
    /**
     * The time provider for this AS.
     */
    private TimeProvider time;
    
    /**
     * The asymmetric key pair of the AS
     */
    private OneKey keyPair;
    
	 /**
	  * Mapping between security identities of the peers and their names; it can be null
	  * 
	  * This is relevant especially for the OSCORE profile, since all peers are registered in the
	  * AS database by nicknames. Instead, their OSCORE identities as retrieved from incoming OSCORE
	  * messages are structured base64 strings encoding the Context ID and Sender ID for that peer 
	 */ 
	private Map<String, String> peerIdentitiesToNames = null;
	
    
    /**
     * Constructor.
     * 
     * @param pdp   the PDP for deciding access
     * @param db  the database connector
     * @param time  the time provider
     * @param keyPair the asymmetric key pair of the AS or null
     * @param peerIdentitiesToNames  mapping between security identities of the peers and their names; it can be null
     *
     * @throws AceException  if fetching the cti from the database fails
     */
    public Introspect(PDP pdp, DBConnector db, 
            TimeProvider time, OneKey keyPair,
            Map<String, String> peerIdentitiesToNames) throws AceException {
        if (pdp == null) {
            LOGGER.severe("Introspect endpoint's PDP was null");
            throw new AceException(
                    "Introspect endpoint's PDP must be non-null");
        }
        if (db == null) {
            LOGGER.severe("Introspect endpoint's DBConnector was null");
            throw new AceException(
                    "Introspect endpoint's DBConnector must be non-null");
        }
        if (time == null) {
            LOGGER.severe("Introspect endpoint received a null TimeProvider");
            throw new AceException(
                    "Introspect endpoint requires a non-null TimeProvider");
        }
        this.pdp = pdp;
        this.db = db;
        this.time = time;  
        this.keyPair = keyPair;
        this.peerIdentitiesToNames = peerIdentitiesToNames;
    }
    
    
	@Override
    public Message processMessage(Message msg) {
		
	    if (msg == null) {
	    	//This should not happen
            LOGGER.severe("Introspect.processMessage() received null message");
            return null;
        }
	    LOGGER.log(Level.INFO, "Introspect received message: " + msg.getParameters());
        
	    
	    // Check that this RS is authorized and allowed to introspect
	    String id = msg.getSenderId();
	    
		if (peerIdentitiesToNames != null) {
		    id = peerIdentitiesToNames.get(id);
		    if (id == null) {
	            CBORObject map = CBORObject.NewMap();
	            map.Add(Constants.ERROR, Constants.UNAUTHORIZED_CLIENT);
	            LOGGER.log(Level.INFO, "Message processing aborted: "
	                    + "unauthorized client: " + id);
	            return msg.failReply(Message.FAIL_UNAUTHORIZED, map);
		    }
		}
	    
        PDP.IntrospectAccessLevel accessLevel;
        try {
            accessLevel = this.pdp.getIntrospectAccessLevel(id);
            if (accessLevel.equals(PDP.IntrospectAccessLevel.NONE)) {
                LOGGER.log(Level.INFO, "Message processing aborted: "
                        + "client: " + id + " does not have the right to introspect");
                return msg.failReply(Message.FAIL_FORBIDDEN, null);
            }
        } catch (AceException e) {
            LOGGER.severe("Database error: " + e.getMessage());
            return msg.failReply(Message.FAIL_INTERNAL_SERVER_ERROR, null);
        }
	    
        
	    // Purge expired tokens from the database
        try {
            this.db.purgeExpiredTokens(this.time.getCurrentTime());
        } catch (AceException e) {
            LOGGER.severe("Database error: " + e.getMessage());
            return msg.failReply(Message.FAIL_INTERNAL_SERVER_ERROR, null);
        }
        
        
	    // Get the token from the introspection request payload
        CBORObject tokenAsCborByteArray = msg.getParameter(Constants.TOKEN);
        if (tokenAsCborByteArray == null) {
            LOGGER.log(Level.INFO, "Request didn't provide 'token' parameter");
            CBORObject map = CBORObject.NewMap();
            map.Add(Constants.ERROR, Constants.INVALID_REQUEST);
            map.Add(Constants.ERROR_DESCRIPTION, "Must provide 'token' parameter");
            return msg.failReply(Message.FAIL_BAD_REQUEST, map);
        }
        CBORObject tokenAsCbor = CBORObject.DecodeFromBytes(
                tokenAsCborByteArray.GetByteString());

        // Parse the token
        AccessToken token;
        try {
            token = parseToken(tokenAsCbor, id);
        } catch (AceException e) {
            LOGGER.log(Level.INFO, e.getMessage());
            CBORObject map = CBORObject.NewMap();
            map.Add(Constants.ERROR, Constants.INVALID_REQUEST);
            map.Add(Constants.ERROR_DESCRIPTION, "Must provide non-null token");
            return msg.failReply(Message.FAIL_BAD_REQUEST, map);
        }


        // Check if token is still active. If it is not, return active=false
        String cti = null;
        try {
			cti = token.getCti();
		} catch (AceException e) {
            LOGGER.severe("Message processing aborted: " + e.getMessage());
            return msg.failReply(Message.FAIL_INTERNAL_SERVER_ERROR, null);
		}
        if (cti == null) {
            LOGGER.log(Level.INFO, "Message processing aborted: the token does not include a valid cti or reference");
            CBORObject map = CBORObject.NewMap();
            map.Add(Constants.ERROR, Constants.INVALID_REQUEST);
            map.Add(Constants.ERROR_DESCRIPTION, "Must provide a token including a valid cti or reference");
            return msg.failReply(Message.FAIL_BAD_REQUEST, map);
        }

        Map<Short, CBORObject> claims;
        CBORObject payload = CBORObject.NewMap();
        try {
            claims = this.db.getClaims(cti);
        } catch (AceException e) {
            LOGGER.severe("Database error: " + e.getMessage());
            return msg.failReply(Message.FAIL_INTERNAL_SERVER_ERROR, null);
        }
        if (claims == null || claims.isEmpty()) {
        	LOGGER.log(Level.INFO, "Returning introspection result: inactive " + "for token: " + cti);
            payload.Add(Constants.ACTIVE, CBORObject.False);
            //No need to check for client token, the token is invalid anyways
            return msg.successReply(Message.CREATED, payload); 
        }
        
                
        // Check if this RS is allowed to introspect this particular Access Token.
        //
        // That is, check if the audience specified in the 'aud' claim of the Access Token
        // includes also this RS. This implies that the Access Token includes the 'aud' claim.
        CBORObject audCbor = claims.get(Constants.AUD);
        if (audCbor == null || audCbor.getType() != CBORType.TextString) {
            LOGGER.severe("Message processing aborted: retrieved token to introspect without a valid audience");
            return msg.failReply(Message.FAIL_INTERNAL_SERVER_ERROR, null);
        }
        
        String aud = audCbor.AsString();
		Set<String> rsSet = new HashSet<>();
		try {
			rsSet = db.getRSS(aud);
		} catch (AceException e) {
            LOGGER.severe("Database error: " + e.getMessage());
		    return msg.failReply(Message.FAIL_INTERNAL_SERVER_ERROR, null);
		}
		if (!rsSet.contains(id)) {
            LOGGER.log(Level.INFO, "RS " + id + " is not allowed to introspect token: " + cti);
            CBORObject map = CBORObject.NewMap();
            map.Add(Constants.ERROR, Constants.INVALID_REQUEST);
            map.Add(Constants.ERROR_DESCRIPTION, "Can introspect only pertaining tokens");
            return msg.failReply(Message.FAIL_BAD_REQUEST, map);
		}

        
        // The NONE option was already checked. Now check if the RS is allowed to 
        // retrieve the full set of claims, or only to the activeness of the token.
        if (accessLevel.equals(PDP.IntrospectAccessLevel.ACTIVE_AND_CLAIMS)) {
            // We have access to all claims; add them to reply.
        	
        	if (claims.containsKey(Constants.LATE_ADDED_EXP)) {
        		// This Access Token was originally created with the EXI claim and without the
        		// EXP claim, which was later artificially added to enable purging upon expiration.
        		//
        		// In order to provide the Resource Server with the Access Token like it was
        		// originally created, such an EXP claim as well as the "sentinel claim" are removed.
        		claims.remove(Constants.LATE_ADDED_EXP);
        		claims.remove(Constants.EXP);
        	}
        	
            payload = Constants.getCBOR(claims);
        }
        else {
            // Only access to activeness.
            payload = CBORObject.NewMap();
        }

        LOGGER.log(Level.INFO, "Returning introspection result: " + payload.toString() + " for " + cti);
        
        payload.Add(Constants.ACTIVE, CBORObject.True);
        return msg.successReply(Message.CREATED, payload);
        
	}

    /**
     * Parses a CBOR object presumably containing an access token.
     * 
     * @param token  the object
     * @param senderId  the sender's id from the secure connection
     * 
     * @return  the parsed access token
     * 
     * @throws AceException 
     */
    public AccessToken parseToken(CBORObject token, String senderId)
            throws AceException {
        if (token == null) {
            throw new AceException("Access token parser indata was null");
        }
        if (token.getType().equals(CBORType.Array)) {
            try {
                // Get the RS id (audience) from the COSE KID header.
				org.eclipse.californium.cose.Message coseRaw = org.eclipse.californium.cose.Message
						.DecodeFromBytes(
                        token.EncodeToBytes());
                CBORObject kid = coseRaw.findAttribute(HeaderKeys.KID);
                Set<String> aud = new HashSet<>();
                if(kid == null) {
                    if (senderId == null) {
                        throw new AceException("Cannot determine Audience"
                                + "of the token for introspection");
                    }
                    aud.add(senderId);
                } else {
                    CBORObject audArray = CBORObject.DecodeFromBytes(
                            kid.GetByteString());
                    for (int i=0; i<audArray.size();i++) {
                        aud.add(audArray.get(i).AsString());
                    }
                }            
                CwtCryptoCtx ctx = EndpointUtils.makeCommonCtx(aud, this.db,
                        this.keyPair, verify);
                return CWT.processCOSE(token.EncodeToBytes(), ctx);
            } catch (Exception e) {
                LOGGER.severe("Error while processing CWT: " + e.getMessage());
                throw new AceException(e.getMessage());
            }
        } else if (token.getType().equals(CBORType.ByteString)) {
            return ReferenceToken.parse(token);
        }
        throw new AceException("Unknown access token format");        
    }


    @Override
    public void close() throws AceException {
        this.db.close();        
    }
    
}