001/*
002 * The contents of this file are subject to the terms of the Common Development and
003 * Distribution License (the License). You may not use this file except in compliance with the
004 * License.
005 *
006 * You can obtain a copy of the License at legal/CDDLv1.0.txt. See the License for the
007 * specific language governing permission and limitations under the License.
008 *
009 * When distributing Covered Software, include this CDDL Header Notice in each file and include
010 * the License file at legal/CDDLv1.0.txt. If applicable, add the following below the CDDL
011 * Header, with the fields enclosed by brackets [] replaced by your own identifying
012 * information: "Portions copyright [year] [name of copyright owner]".
013 *
014 * Copyright 2014-2015 ForgeRock AS.
015 */
016
017package org.forgerock.openig.jwt;
018
019import static java.lang.String.format;
020import static java.util.Collections.singletonList;
021import static java.util.concurrent.TimeUnit.MILLISECONDS;
022import static java.util.concurrent.TimeUnit.MINUTES;
023import static org.forgerock.http.util.Json.*;
024import static org.forgerock.openig.jwt.JwtSessionManager.MAX_SESSION_TIMEOUT;
025
026import java.io.IOException;
027import java.security.KeyPair;
028import java.util.Collection;
029import java.util.Date;
030import java.util.LinkedHashMap;
031import java.util.List;
032import java.util.Map;
033import java.util.Set;
034
035import org.forgerock.http.header.SetCookieHeader;
036import org.forgerock.http.protocol.Cookie;
037import org.forgerock.http.protocol.Request;
038import org.forgerock.http.protocol.Response;
039import org.forgerock.http.session.Session;
040import org.forgerock.json.jose.builders.EncryptedJwtBuilder;
041import org.forgerock.json.jose.builders.JwtBuilderFactory;
042import org.forgerock.json.jose.builders.JwtClaimsSetBuilder;
043import org.forgerock.json.jose.common.JwtReconstruction;
044import org.forgerock.json.jose.exceptions.JweDecryptionException;
045import org.forgerock.json.jose.jwe.EncryptedJwt;
046import org.forgerock.json.jose.jwe.EncryptionMethod;
047import org.forgerock.json.jose.jwe.JweAlgorithm;
048import org.forgerock.json.jose.jwt.JwtClaimsSet;
049import org.forgerock.openig.jwt.dirty.DirtyCollection;
050import org.forgerock.openig.jwt.dirty.DirtyListener;
051import org.forgerock.openig.jwt.dirty.DirtySet;
052import org.forgerock.openig.log.Logger;
053import org.forgerock.util.MapDecorator;
054import org.forgerock.util.Reject;
055import org.forgerock.util.time.Duration;
056import org.forgerock.util.time.TimeService;
057
058/**
059 * Represents an OpenIG {@link Session} that will be stored as an encrypted JSON Web Token in a Cookie.
060 * The generated JWT is encrypted with the {@link JweAlgorithm#RSAES_PKCS1_V1_5} algorithm and {@link
061 * EncryptionMethod#A128CBC_HS256} method.
062 */
063public class JwtCookieSession extends MapDecorator<String, Object> implements Session, DirtyListener {
064
065    /**
066     * Name of the cookie that will store the JWT session.
067     */
068    public static final String OPENIG_JWT_SESSION = "openig-jwt-session";
069
070    /**
071     * Based on the EXP claim concept from from rfc7519: The amount of time allowance between JWT expiring and current
072     * time when using EXP claim. Implementers MAY provide for some small leeway, usually no more than a few minutes,
073     * to account for clock skew.
074     */
075    private static final long SKEW_ALLOWANCE = MILLISECONDS.convert(2L, MINUTES);
076
077    /**
078     * This key will hold the sessionTimeout value within the JWT session.
079     */
080    private static final String IG_EXP_SESSION_KEY = "_ig_exp";
081
082    /**
083     * Setting sessionTimeout to this date will effectively remove it from the user agent.
084     */
085    private static final Date EPOCH = new Date(0L);
086
087    /**
088     * Know how to rebuild a JWT from a String.
089     */
090    private final JwtReconstruction reader = new JwtReconstruction();
091
092    /**
093     * Factory for JWT.
094     */
095    private final JwtBuilderFactory factory = new JwtBuilderFactory();
096
097    /**
098     * Marker used to detect if the session was used or not.
099     */
100    private boolean dirty;
101
102    /**
103     * Name to be used for the JWT Cookie.
104     */
105    private final String cookieName;
106
107    /**
108     * Logger used to output warnings about session's size.
109     */
110    private final Logger logger;
111
112    /**
113     * Used for decryption/encryption of session's content.
114     */
115    private final KeyPair pair;
116
117    /**
118     * The TimeService to use when setting the cookie session expiry time.
119     */
120    private final TimeService timeService;
121
122    /**
123     * How long before the cookie session expires.
124     */
125    private final Duration sessionTimeout;
126
127    /**
128     * Builds a new JwtCookieSession that will manage the given Request's session.
129     *
130     * @param request
131     *         Request used to access {@literal Cookie} and {@literal Set-Cookie} headers.
132     * @param pair
133     *         Secret key used to sign the JWT payload.
134     * @param cookieName
135     *         Name to be used for the JWT Cookie.
136     * @param logger
137     *         Logger
138     * @param timeService
139     *         TimeService to use when dealing with cookie sessions
140     * @param sessionTimeout
141     *         The duration of the cookie session
142     */
143    public JwtCookieSession(final Request request,
144                            final KeyPair pair,
145                            final String cookieName,
146                            final Logger logger,
147                            final TimeService timeService,
148                            final Duration sessionTimeout) {
149        super(new LinkedHashMap<String, Object>());
150        this.pair = pair;
151        this.cookieName = cookieName;
152        this.logger = logger;
153        this.timeService = timeService;
154
155        // The MAX_SESSION_TIMEOUT is more than enough to mark a session to not expire
156        // so use this in place of larger values.
157        if (sessionTimeout.to(MILLISECONDS) > MAX_SESSION_TIMEOUT.to(MILLISECONDS)) {
158            this.sessionTimeout = MAX_SESSION_TIMEOUT;
159        } else {
160            this.sessionTimeout = sessionTimeout;
161        }
162
163        // TODO Make this lazy (intercept read methods)
164        loadJwtSession(request);
165    }
166
167    /**
168     * Load the session's content from the cookie (if any).
169     *
170     * @param request Request used to access {@literal Cookie} and {@literal Set-Cookie} headers.
171     */
172    private void loadJwtSession(Request request) {
173        Cookie cookie = findJwtSessionCookie(request);
174        if (cookie != null) {
175            try {
176                EncryptedJwt jwt = reader.reconstructJwt(cookie.getValue(), EncryptedJwt.class);
177                jwt.decrypt(pair.getPrivate());
178                JwtClaimsSet claimsSet = jwt.getClaimsSet();
179                for (String key : claimsSet.keys()) {
180                    // directly use super to avoid session be marked as dirty
181                    super.put(key, claimsSet.getClaim(key));
182                }
183                Number expiryTime = (Number) get(IG_EXP_SESSION_KEY);
184                if (expiryTime != null) {
185                    if (isExpired(expiryTime)) {
186                        logger.debug("The JWT Session Cookie has expired");
187                        clear();
188                    }
189                } else {
190                    // No expiry time in the JWT: must be an old session from OpenIG 3.x
191                    // Force a new entry, this will mark the session as dirty
192                    // but will keep the session's content with an expiration date
193                    put(IG_EXP_SESSION_KEY, getNewExpiryTime());
194                }
195            } catch (JweDecryptionException e) {
196                dirty = true; // Force cookie expiration / overwrite.
197                logger.warning(format("The JWT Session Cookie '%s' could not be decrypted. This "
198                        + "may be because temporary encryption keys have been used or if the "
199                        + "configured encryption keys have changed since the JWT Session Cookie "
200                        + "was created", cookieName));
201                logger.debug(e);
202            } catch (Exception e) {
203                dirty = true; // Force cookie expiration / overwrite.
204                logger.warning(format("Cannot rebuild JWT Session from Cookie '%s'", cookieName));
205                logger.debug(e);
206            }
207        }
208    }
209
210    @Override
211    public void onElementsRemoved() {
212        dirty = true;
213    }
214
215    @Override
216    public Object put(final String key, final Object value) {
217        // Put null into a key, results in the complete entry removal
218        if (value == null) {
219            return remove(key);
220        }
221        // Verify that the given value is JSON compatible
222        // This will throw an Exception if not
223        checkJsonCompatibility(key, value);
224
225        // Mark the session as dirty
226        dirty = true;
227
228        // Store the value
229        return super.put(key, value);
230    }
231
232    @Override
233    public void putAll(final Map<? extends String, ?> m) {
234        for (Entry<? extends String, ?> entry : m.entrySet()) {
235            put(entry.getKey(), entry.getValue());
236        }
237    }
238
239    @Override
240    public Object remove(final Object key) {
241        dirty = true;
242        return super.remove(key);
243    }
244
245    @Override
246    public void clear() {
247        dirty = true;
248        super.clear();
249    }
250
251    @Override
252    public Set<String> keySet() {
253        return new DirtySet<>(super.keySet(), this);
254    }
255
256    @Override
257    public Collection<Object> values() {
258        return new DirtyCollection<>(super.values(), this);
259    }
260
261    @Override
262    public Set<Entry<String, Object>> entrySet() {
263        return new DirtySet<>(super.entrySet(), this);
264    }
265
266    @Override
267    public void save(Response response) throws IOException {
268        // Only build the JWT session if the session is dirty
269        if (dirty) {
270            Reject.ifNull(response, "Cannot save session state on a null response");
271            // Update the Set-Cookie header
272            final Cookie jwtCookie;
273            if (isEmpty()) {
274                jwtCookie = buildExpiredJwtCookie();
275            } else {
276                jwtCookie = buildJwtCookie();
277                String value = jwtCookie.getValue();
278                if (value.length() > 4096) {
279                    throw new IOException(
280                            format("JWT session is too large (%d chars), failing the request because "
281                                    + "session does not support serialized content that is larger than 4KB "
282                                    + "(Http Cookie limitation)", value.length()));
283                }
284                if (value.length() > 3072) {
285                    logger.warning(format(
286                            "Current JWT session's size (%d chars) is quite close to the 4KB limit. Maybe "
287                                    + "consider using the traditional Http-based session (the default), or place"
288                                    + "less objects in the session", value.length()));
289                }
290            }
291            response.getHeaders().add(new SetCookieHeader(singletonList(jwtCookie)));
292        }
293
294    }
295
296    @Override
297    public boolean isEmpty() {
298
299        // If the only item is the IG_EXP_SESSION_KEY then it should be considered empty
300        if (!super.isEmpty()) {
301            return super.size() == 1 && super.containsKey(IG_EXP_SESSION_KEY);
302        } else {
303            return true;
304        }
305    }
306
307    private Cookie buildExpiredJwtCookie() {
308        return new Cookie().setPath("/").setName(cookieName).setExpires(EPOCH);
309    }
310
311    private Cookie buildJwtCookie() {
312        // Reuse existing expiryTime if it exists.
313        // If the value fits within a Integer, then an Integer rather than a Long is returned.
314        Number expiryTime = (Number) get(IG_EXP_SESSION_KEY);
315        if (expiryTime == null) {
316            expiryTime = getNewExpiryTime();
317            super.put(IG_EXP_SESSION_KEY, expiryTime.longValue());
318        }
319        return new Cookie()
320                .setPath("/")
321                .setName(cookieName)
322                .setValue(buildJwtSession())
323                .setExpires(new Date(expiryTime.longValue()));
324    }
325
326    /**
327     * Builds a JWT from the session's content.
328     */
329    private String buildJwtSession() {
330        EncryptedJwtBuilder jwtBuilder = factory.jwe(pair.getPublic());
331        JwtClaimsSetBuilder claimsBuilder = factory.claims();
332        claimsBuilder.claims(this);
333        jwtBuilder.claims(claimsBuilder.build());
334        jwtBuilder.headers()
335                  .alg(JweAlgorithm.RSAES_PKCS1_V1_5)
336                  .enc(EncryptionMethod.A128CBC_HS256);
337        return jwtBuilder.build();
338    }
339
340    /**
341     * Find if there is an existing cookie storing a JWT session.
342     *
343     * @param request Request used to access {@literal Cookie} and {@literal Set-Cookie} headers.
344     * @return a {@link Cookie} if found, {@literal null} otherwise.
345     */
346    private Cookie findJwtSessionCookie(Request request) {
347        List<Cookie> cookies = request.getCookies().get(cookieName);
348        if (cookies != null) {
349            return cookies.get(0);
350        }
351        return null;
352    }
353
354    private Long getNewExpiryTime() {
355        return timeService.now() + sessionTimeout.to(MILLISECONDS);
356    }
357
358    private boolean isExpired(Number expiryTime) {
359        return expiryTime.longValue() <= (timeService.now() - SKEW_ALLOWANCE);
360    }
361}