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 ForgeRock AS.
015 */
016
017package org.forgerock.openig.jwt;
018
019import static java.lang.String.*;
020import static org.forgerock.openig.util.Json.*;
021
022import java.io.IOException;
023import java.security.KeyPair;
024import java.util.Collection;
025import java.util.LinkedHashMap;
026import java.util.List;
027import java.util.Map;
028import java.util.Set;
029
030import org.forgerock.json.jose.builders.EncryptedJwtBuilder;
031import org.forgerock.json.jose.builders.JwtBuilderFactory;
032import org.forgerock.json.jose.builders.JwtClaimsSetBuilder;
033import org.forgerock.json.jose.common.JwtReconstruction;
034import org.forgerock.json.jose.exceptions.JweDecryptionException;
035import org.forgerock.json.jose.jwe.EncryptedJwt;
036import org.forgerock.json.jose.jwe.EncryptionMethod;
037import org.forgerock.json.jose.jwe.JweAlgorithm;
038import org.forgerock.json.jose.jwt.JwtClaimsSet;
039import org.forgerock.openig.http.Cookie;
040import org.forgerock.openig.http.Exchange;
041import org.forgerock.openig.http.Session;
042import org.forgerock.openig.jwt.dirty.DirtyCollection;
043import org.forgerock.openig.jwt.dirty.DirtyListener;
044import org.forgerock.openig.jwt.dirty.DirtySet;
045import org.forgerock.openig.log.Logger;
046import org.forgerock.util.MapDecorator;
047
048/**
049 * Represents an OpenIG {@link Session} that will be stored as an encrypted JSON Web Token in a Cookie.
050 * The generated JWT is encrypted with the {@link JweAlgorithm#RSAES_PKCS1_V1_5} algorithm and {@link
051 * EncryptionMethod#A128CBC_HS256} method.
052 */
053public class JwtCookieSession extends MapDecorator<String, Object> implements Session, DirtyListener {
054
055    /**
056     * Name of the cookie that will store the JWT session.
057     */
058    public static final String OPENIG_JWT_SESSION = "openig-jwt-session";
059
060    /**
061     * {@literal exchange.request} will be used to read existing cookie (if any), and {@literal exchange.response} will
062     * be used to write the new cookie value.
063     */
064    private final Exchange exchange;
065
066    /**
067     * Know how to rebuild a JWT from a String.
068     */
069    private final JwtReconstruction reader = new JwtReconstruction();
070
071    /**
072     * Factory for JWT.
073     */
074    private final JwtBuilderFactory factory = new JwtBuilderFactory();
075
076    /**
077     * Marker used to detect if the session was used or not.
078     */
079    private boolean dirty = false;
080
081    /**
082     * Name to be used for the JWT Cookie.
083     */
084    private final String cookieName;
085
086    /**
087     * Logger used to output warnings about session's size.
088     */
089    private final Logger logger;
090
091    /**
092     * Used for decryption/encryption of session's content.
093     */
094    private final KeyPair pair;
095
096    /**
097     * Builds a new JwtCookieSession that will manage the given Exchange's session.
098     *
099     * @param exchange
100     *         Exchange used to access {@literal Cookie} and {@literal Set-Cookie} headers.
101     * @param pair
102     *         Secret key used to sign the JWT payload.
103     * @param cookieName
104     *         Name to be used for the JWT Cookie.
105     * @param logger
106     *         Logger
107     */
108    public JwtCookieSession(final Exchange exchange,
109                            final KeyPair pair,
110                            final String cookieName,
111                            final Logger logger) {
112        super(new LinkedHashMap<String, Object>());
113        this.exchange = exchange;
114        this.pair = pair;
115        this.cookieName = cookieName;
116        this.logger = logger;
117
118        // TODO Make this lazy (intercept read methods)
119        loadJwtSession();
120    }
121
122    /**
123     * Load the session's content from the cookie (if any).
124     */
125    private void loadJwtSession() {
126        Cookie cookie = findJwtSessionCookie();
127        if (cookie != null) {
128            try {
129                EncryptedJwt jwt = reader.reconstructJwt(cookie.getValue(), EncryptedJwt.class);
130                jwt.decrypt(pair.getPrivate());
131                JwtClaimsSet claimsSet = jwt.getClaimsSet();
132                for (String key : claimsSet.keys()) {
133                    // directly use super to avoid session be marked as dirty
134                    super.put(key, claimsSet.getClaim(key));
135                }
136            } catch (JweDecryptionException e) {
137                dirty = true; // Force cookie expiration / overwrite.
138                logger.warning(format("The JWT Session Cookie '%s' could not be decrypted. This "
139                        + "may be because temporary encryption keys have been used or if the "
140                        + "configured encryption keys have changed since the JWT Session Cookie "
141                        + "was created", cookieName));
142                logger.debug(e);
143            } catch (Exception e) {
144                dirty = true; // Force cookie expiration / overwrite.
145                logger.warning(format("Cannot rebuild JWT Session from Cookie '%s'", cookieName));
146                logger.debug(e);
147            }
148        }
149    }
150
151    @Override
152    public void onElementsRemoved() {
153        dirty = true;
154    }
155
156    @Override
157    public Object put(final String key, final Object value) {
158        // Put null into a key, results in the complete entry removal
159        if (value == null) {
160            return remove(key);
161        }
162        // Verify that the given value is JSON compatible
163        // This will throw an Exception if not
164        checkJsonCompatibility(key, value);
165
166        // Mark the session as dirty
167        dirty = true;
168
169        // Store the value
170        return super.put(key, value);
171    }
172
173    @Override
174    public void putAll(final Map<? extends String, ?> m) {
175        for (Entry<? extends String, ?> entry : m.entrySet()) {
176            put(entry.getKey(), entry.getValue());
177        }
178    }
179
180    @Override
181    public Object remove(final Object key) {
182        dirty = true;
183        return super.remove(key);
184    }
185
186    @Override
187    public void clear() {
188        dirty = true;
189        super.clear();
190    }
191
192    @Override
193    public Set<String> keySet() {
194        return new DirtySet<String>(super.keySet(), this);
195    }
196
197    @Override
198    public Collection<Object> values() {
199        return new DirtyCollection<Object>(super.values(), this);
200    }
201
202    @Override
203    public Set<Entry<String, Object>> entrySet() {
204        return new DirtySet<Entry<String, Object>>(super.entrySet(), this);
205    }
206
207    @Override
208    public void close() throws IOException {
209        // Only build the JWT session if the session is dirty
210        if (dirty) {
211            // Update the Set-Cookie header
212            final String value;
213            if (isEmpty()) {
214                value = buildExpiredJwtCookie();
215            } else {
216                value = buildJwtCookie();
217                if (value.length() > 4096) {
218                    throw new IOException(
219                            format("JWT session is too large (%d chars), failing the request because "
220                                    + "session does not support serialized content that is larger than 4KB "
221                                    + "(Http Cookie limitation)", value.length()));
222                }
223                if (value.length() > 3072) {
224                    logger.warning(format(
225                            "Current JWT session's size (%d chars) is quite close to the 4KB limit. Maybe "
226                                    + "consider using the traditional Http-based session (the default), or place"
227                                    + "less objects in the session", value.length()));
228                }
229            }
230            exchange.response.getHeaders().add("Set-Cookie", value);
231        }
232
233    }
234
235    private String buildExpiredJwtCookie() {
236        return format("%s=; Path=/; Max-Age=-1", cookieName);
237    }
238
239    private String buildJwtCookie() {
240        return format("%s=%s; Path=%s", cookieName, buildJwtSession(), "/");
241    }
242
243    /**
244     * Builds a JWT from the session's content.
245     */
246    private String buildJwtSession() {
247        EncryptedJwtBuilder jwtBuilder = factory.jwe(pair.getPublic());
248        JwtClaimsSetBuilder claimsBuilder = factory.claims();
249        claimsBuilder.claims(this);
250        jwtBuilder.claims(claimsBuilder.build());
251        jwtBuilder.headers()
252                  .alg(JweAlgorithm.RSAES_PKCS1_V1_5)
253                  .enc(EncryptionMethod.A128CBC_HS256);
254        return jwtBuilder.build();
255    }
256
257    /**
258     * Find if there is an existing cookie storing a JWT session.
259     *
260     * @return a {@link Cookie} if found, {@literal null} otherwise.
261     */
262    private Cookie findJwtSessionCookie() {
263        List<Cookie> cookies = exchange.request.getCookies().get(cookieName);
264        if (cookies != null) {
265            return cookies.get(0);
266        }
267        return null;
268    }
269}