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.lang.reflect.Field;
026import java.time.Instant;
027import java.util.ArrayList;
028import java.util.Date;
029import java.util.List;
030import java.util.Map;
031import java.util.Map.Entry;
032import java.util.Objects;
033
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.local.ClientLoginModule;
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, checked during validation
145            builder.withIssuer(NUXEO_ISSUER);
146            // default to current principal as subject
147            String subject = ClientLoginModule.getCurrentPrincipal().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) //
238                                  .withIssuer(NUXEO_ISSUER)
239                                  .build();
240        DecodedJWT jwt;
241        try {
242            jwt = verifier.verify(token);
243        } catch (JWTVerificationException e) {
244            if (log.isTraceEnabled()) {
245                log.trace("token verification failed: " + e.toString());
246            }
247            return null; // invalid
248        }
249        Object payload = getFieldValue(jwt, "payload"); // com.auth0.jwt.impl.PayloadImpl
250        Map<String, JsonNode> tree = getFieldValue(payload, "tree");
251        return tree.entrySet().stream().collect(toMap(Entry::getKey, e -> nodeToValue(e.getValue())));
252    }
253
254    /**
255     * Converts a {@link JsonNode} to a Java value.
256     */
257    protected static Object nodeToValue(JsonNode node) {
258        if (node == null || node.isNull() || node.isMissingNode()) {
259            return null;
260        } else if (node.isObject()) {
261            try {
262                try (JsonParser parser = OBJECT_MAPPER.treeAsTokens(node)) {
263                    return parser.readValueAs(MAP_STRING_OBJECT);
264                }
265            } catch (IOException e) {
266                throw new NuxeoException("Cannot map claim value to Map", e);
267            }
268        } else if (node.isArray()) {
269            List<Object> list = new ArrayList<>();
270            for (JsonNode elem : node) {
271                try {
272                    list.add(OBJECT_MAPPER.treeToValue(elem, Object.class));
273                } catch (IOException e) {
274                    throw new NuxeoException("Cannot map Claim array value to Object", e);
275                }
276            }
277            return list;
278        } else {
279            // Jackson doesn't seem to have an easy way to do this, other than checking each possible type
280            Object value;
281            try {
282                value = getFieldValue(node, "_value");
283            } catch (NuxeoException e) {
284                log.warn("Cannot extract primitive value from JsonNode: " + node.getClass().getName());
285                value = null;
286            }
287            if (value instanceof Integer) {
288                // normalize to Long for caller convenience
289                value = Long.valueOf(((Integer) value).longValue());
290            }
291            return value;
292        }
293    }
294
295    protected int getDefaultTTL() {
296        return registry.getContribution().getDefaultTTL();
297    }
298
299    protected Algorithm getAlgorithm() {
300        String secret = registry.getContribution().getSecret();
301        if (isBlank(secret)) {
302            return null;
303        }
304        return Algorithm.HMAC512(secret);
305    }
306
307    @SuppressWarnings("unchecked")
308    protected static <T> T getFieldValue(Object object, String name) {
309        try {
310            Field field = object.getClass().getDeclaredField(name);
311            field.setAccessible(true);
312            return (T) field.get(object);
313        } catch (ReflectiveOperationException | SecurityException | IllegalArgumentException e) {
314            throw new NuxeoException(e);
315        }
316    }
317
318}