001/*
002 * (C) Copyright 2018 Nuxeo (http://nuxeo.com/) and others.
003 *
004 * Licensed under the Apache License, Version 2.0 (the "License");
005 * you may not use this file except in compliance with the License.
006 * You may obtain a copy of the License at
007 *
008 *     http://www.apache.org/licenses/LICENSE-2.0
009 *
010 * Unless required by applicable law or agreed to in writing, software
011 * distributed under the License is distributed on an "AS IS" BASIS,
012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
013 * See the License for the specific language governing permissions and
014 * limitations under the License.
015 *
016 * Contributors:
017 *     Florent Guillaume
018 */
019package org.nuxeo.ecm.jwt;
020
021import static java.util.stream.Collectors.toMap;
022import static org.apache.commons.lang3.StringUtils.isBlank;
023
024import java.io.IOException;
025import java.time.Instant;
026import java.util.ArrayList;
027import java.util.Date;
028import java.util.List;
029import java.util.Map;
030import java.util.Map.Entry;
031import java.util.Objects;
032
033import org.apache.commons.lang3.reflect.FieldUtils;
034import org.apache.commons.logging.Log;
035import org.apache.commons.logging.LogFactory;
036import org.nuxeo.ecm.core.api.NuxeoException;
037import org.nuxeo.ecm.core.api.NuxeoPrincipal;
038import org.nuxeo.runtime.model.ComponentInstance;
039import org.nuxeo.runtime.model.DefaultComponent;
040import org.nuxeo.runtime.model.SimpleContributionRegistry;
041
042import com.auth0.jwt.JWT;
043import com.auth0.jwt.JWTCreator.Builder;
044import com.auth0.jwt.JWTVerifier;
045import com.auth0.jwt.algorithms.Algorithm;
046import com.auth0.jwt.exceptions.JWTCreationException;
047import com.auth0.jwt.exceptions.JWTVerificationException;
048import com.auth0.jwt.interfaces.DecodedJWT;
049import com.fasterxml.jackson.core.JsonParser;
050import com.fasterxml.jackson.core.type.TypeReference;
051import com.fasterxml.jackson.databind.JsonNode;
052import com.fasterxml.jackson.databind.ObjectMapper;
053
054/**
055 * The JSON Web Token Service implementation.
056 *
057 * @since 10.3
058 */
059public class JWTServiceImpl extends DefaultComponent implements JWTService {
060
061    private static final Log log = LogFactory.getLog(JWTServiceImpl.class);
062
063    public static final String XP_CONFIGURATION = "configuration";
064
065    public static final String NUXEO_ISSUER = "nuxeo";
066
067    protected static final ObjectMapper OBJECT_MAPPER = new ObjectMapper();
068
069    protected static final TypeReference<Map<String, Object>> MAP_STRING_OBJECT = new TypeReference<Map<String, Object>>() {
070    };
071
072    protected static class JWTServiceConfigurationRegistry
073            extends SimpleContributionRegistry<JWTServiceConfigurationDescriptor> {
074
075        protected static final String KEY = ""; // value doesn't matter as long as we use a fixed one
076
077        protected static final JWTServiceConfigurationDescriptor DEFAULT_CONTRIBUTION = new JWTServiceConfigurationDescriptor();
078
079        @Override
080        public String getContributionId(JWTServiceConfigurationDescriptor contrib) {
081            return KEY;
082        }
083
084        @Override
085        public boolean isSupportingMerge() {
086            return true;
087        }
088
089        @Override
090        public JWTServiceConfigurationDescriptor clone(JWTServiceConfigurationDescriptor orig) {
091            return new JWTServiceConfigurationDescriptor(orig);
092        }
093
094        @Override
095        public void merge(JWTServiceConfigurationDescriptor src, JWTServiceConfigurationDescriptor dst) {
096            dst.merge(src);
097        }
098
099        public JWTServiceConfigurationDescriptor getContribution() {
100            JWTServiceConfigurationDescriptor contribution = getContribution(KEY);
101            if (contribution == null) {
102                contribution = DEFAULT_CONTRIBUTION;
103            }
104            return contribution;
105        }
106    }
107
108    protected final JWTServiceConfigurationRegistry registry = new JWTServiceConfigurationRegistry();
109
110    @Override
111    public void registerContribution(Object contribution, String extensionPoint, ComponentInstance contributor) {
112        if (XP_CONFIGURATION.equals(extensionPoint)) {
113            registry.addContribution((JWTServiceConfigurationDescriptor) contribution);
114        } else {
115            throw new NuxeoException("Unknown extension point: " + extensionPoint);
116        }
117    }
118
119    @Override
120    public void unregisterContribution(Object contribution, String extensionPoint, ComponentInstance contributor) {
121        if (XP_CONFIGURATION.equals(extensionPoint)) {
122            registry.removeContribution((JWTServiceConfigurationDescriptor) contribution);
123        }
124    }
125
126    // -------------------- JWTService API --------------------
127
128    @Override
129    public JWTBuilder newBuilder() {
130        return new JWTBuilderImpl();
131    }
132
133    /**
134     * Implementation of {@link JWTBuilder} delegating to the auth0 JWT library.
135     *
136     * @since 10.3
137     */
138    public class JWTBuilderImpl implements JWTBuilder {
139
140        public final Builder builder;
141
142        public JWTBuilderImpl() {
143            builder = JWT.create();
144            // default Nuxeo issuer
145            builder.withIssuer(NUXEO_ISSUER);
146            // default to current principal as subject
147            String subject = NuxeoPrincipal.getCurrent().getActingUser();
148            if (subject == null) {
149                throw new NuxeoException("No currently logged-in user");
150            }
151            builder.withSubject(subject);
152            // default TTL
153            withTTL(0);
154        }
155
156        @Override
157        public JWTBuilderImpl withTTL(int ttlSeconds) {
158            if (ttlSeconds <= 0) {
159                ttlSeconds = getDefaultTTL();
160            }
161            builder.withExpiresAt(Date.from(Instant.now().plusSeconds(ttlSeconds)));
162            return this;
163        }
164
165        @Override
166        public JWTBuilderImpl withClaim(String name, Object value) {
167            if (value instanceof Boolean) {
168                builder.withClaim(name, (Boolean) value);
169            } else if (value instanceof Date) {
170                builder.withClaim(name, (Date) value);
171            } else if (value instanceof Double) {
172                builder.withClaim(name, (Double) value);
173            } else if (value instanceof Integer) {
174                builder.withClaim(name, (Integer) value);
175            } else if (value instanceof Long) {
176                builder.withClaim(name, (Long) value);
177            } else if (value instanceof String) {
178                builder.withClaim(name, (String) value);
179            } else if (value instanceof Integer[]) {
180                builder.withArrayClaim(name, (Integer[]) value);
181            } else if (value instanceof Long[]) {
182                builder.withArrayClaim(name, (Long[]) value);
183            } else if (value instanceof String[]) {
184                builder.withArrayClaim(name, (String[]) value);
185            } else {
186                throw new NuxeoException("Unknown claim type: " + value);
187            }
188            return this;
189        }
190
191        @Override
192        public String build() {
193            try {
194                Algorithm algorithm = getAlgorithm();
195                if (algorithm == null) {
196                    throw new NuxeoException("JWTService secret not configured");
197                }
198                return builder.sign(algorithm);
199            } catch (JWTCreationException e) {
200                throw new NuxeoException(e);
201            }
202        }
203    }
204
205    protected void builderWithClaim(Builder builder, String name, Object value) {
206        if (value instanceof Boolean) {
207            builder.withClaim(name, (Boolean) value);
208        } else if (value instanceof Date) {
209            builder.withClaim(name, (Date) value);
210        } else if (value instanceof Double) {
211            builder.withClaim(name, (Double) value);
212        } else if (value instanceof Integer) {
213            builder.withClaim(name, (Integer) value);
214        } else if (value instanceof Long) {
215            builder.withClaim(name, (Long) value);
216        } else if (value instanceof String) {
217            builder.withClaim(name, (String) value);
218        } else if (value instanceof Integer[]) {
219            builder.withArrayClaim(name, (Integer[]) value);
220        } else if (value instanceof Long[]) {
221            builder.withArrayClaim(name, (Long[]) value);
222        } else if (value instanceof String[]) {
223            builder.withArrayClaim(name, (String[]) value);
224        } else {
225            throw new NuxeoException("Unknown claim type: " + value);
226        }
227    }
228
229    @Override
230    public Map<String, Object> verifyToken(String token) {
231        Objects.requireNonNull(token);
232        Algorithm algorithm = getAlgorithm();
233        if (algorithm == null) {
234            log.debug("secret not configured, cannot verify token");
235            return null; // no secret
236        }
237        JWTVerifier verifier = JWT.require(algorithm).build();
238        DecodedJWT jwt;
239        try {
240            jwt = verifier.verify(token);
241        } catch (JWTVerificationException e) {
242            if (log.isTraceEnabled()) {
243                log.trace("token verification failed: " + e.toString());
244            }
245            return null; // invalid
246        }
247        Map<String, JsonNode> tree;
248        try {
249            Object payload = FieldUtils.readField(jwt, "payload", true); // com.auth0.jwt.impl.PayloadImpl
250            tree = (Map<String, JsonNode>) FieldUtils.readField(payload, "tree", true);
251        } catch (ReflectiveOperationException e) {
252            throw new NuxeoException(e);
253        }
254        return tree.entrySet().stream().collect(toMap(Entry::getKey, e -> nodeToValue(e.getValue())));
255    }
256
257    /**
258     * Converts a {@link JsonNode} to a Java value.
259     */
260    protected static Object nodeToValue(JsonNode node) {
261        if (node == null || node.isNull() || node.isMissingNode()) {
262            return null;
263        } else if (node.isObject()) {
264            try {
265                try (JsonParser parser = OBJECT_MAPPER.treeAsTokens(node)) {
266                    return parser.readValueAs(MAP_STRING_OBJECT);
267                }
268            } catch (IOException e) {
269                throw new NuxeoException("Cannot map claim value to Map", e);
270            }
271        } else if (node.isArray()) {
272            List<Object> list = new ArrayList<>();
273            for (JsonNode elem : node) {
274                try {
275                    list.add(OBJECT_MAPPER.treeToValue(elem, Object.class));
276                } catch (IOException e) {
277                    throw new NuxeoException("Cannot map Claim array value to Object", e);
278                }
279            }
280            return list;
281        } else {
282            // Jackson doesn't seem to have an easy way to do this, other than checking each possible type
283            Object value;
284            try {
285                value = FieldUtils.readField(node, "_value", true);
286            } catch (ReflectiveOperationException e) {
287                log.warn("Cannot extract primitive value from JsonNode: " + node.getClass().getName());
288                value = null;
289            }
290            if (value instanceof Integer) {
291                // normalize to Long for caller convenience
292                value = Long.valueOf(((Integer) value).longValue());
293            }
294            return value;
295        }
296    }
297
298    protected int getDefaultTTL() {
299        return registry.getContribution().getDefaultTTL();
300    }
301
302    protected Algorithm getAlgorithm() {
303        String secret = registry.getContribution().getSecret();
304        if (isBlank(secret)) {
305            return null;
306        }
307        return Algorithm.HMAC512(secret);
308    }
309
310}