001/*
002 * Licensed to the Apache Software Foundation (ASF) under one or more
003 * contributor license agreements.  See the NOTICE file distributed with
004 * this work for additional information regarding copyright ownership.
005 * The ASF licenses this file to You under the Apache License, Version 2.0
006 * (the "License"); you may not use this file except in compliance with
007 * the License.  You may obtain a copy of the License at
008 *
009 *      http://www.apache.org/licenses/LICENSE-2.0
010 *
011 * Unless required by applicable law or agreed to in writing, software
012 * distributed under the License is distributed on an "AS IS" BASIS,
013 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
014 * See the License for the specific language governing permissions and
015 * limitations under the License.
016 */
017package org.apache.juneau.rest.server.auth.oidc.rp;
018
019import static org.apache.juneau.commons.utils.Shorts.*;
020
021import java.nio.charset.*;
022import java.time.*;
023import java.util.*;
024
025import org.apache.juneau.rest.server.auth.*;
026
027import com.nimbusds.jose.*;
028import com.nimbusds.jose.crypto.*;
029import com.nimbusds.jwt.*;
030
031/**
032 * Stateless {@link SessionStore} that serializes the session into an HMAC-signed compact JWT carried
033 * entirely in the session cookie.  This is the documented default.
034 *
035 * <p>
036 * Because the session lives client-side, this store scales horizontally and survives server restarts
037 * with no shared store.  The trade-offs:
038 * <ul>
039 *    <li><b>No server-side revocation</b> &mdash; {@link #supportsServerSideRevocation()} is
040 *       <jk>false</jk>; a stateless cookie cannot be force-revoked, so this store does NOT support
041 *       back-channel logout.  Use {@link InMemorySessionStore} or a caller-supplied distributed store
042 *       if back-channel logout is required.
043 *    <li><b>Size cap</b> &mdash; cookies are capped (~4&nbsp;KB); only minimal claims are stored, never
044 *       the raw access / refresh / ID tokens.  An over-cap payload throws rather than silently truncating.
045 *    <li><b>Key rotation</b> &mdash; rotating the signing key invalidates all live sessions (users are
046 *       bounced to re-login).
047 * </ul>
048 *
049 * <p>
050 * The payload is signed with {@code HS256} using the configured signing key (which must be at least 256
051 * bits / 32 bytes).  Signature failures, expiry, and parse errors all resolve to
052 * {@link Optional#empty()} on {@link #lookup(String)} (fail-closed).
053 *
054 * @since 10.0.0
055 */
056@SuppressWarnings({
057   "java:S1192" // Duplicate string literals are cookie attribute names and session field names; intentional
058})
059public class SignedCookieSessionStore implements SessionStore {
060
061   /** Default maximum serialized cookie size in bytes. */
062   public static final int DEFAULT_MAX_COOKIE_BYTES = 4096;
063
064   private static final String CLAIM_NAME = "n";
065   private static final String CLAIM_ROLES = "r";
066   private static final String CLAIM_SID = "sid";
067   private static final String CLAIM_CLAIMS = "c";
068
069   /**
070    * Static creator.
071    *
072    * @return A new builder.
073    */
074   public static Builder create() {
075      return new Builder();
076   }
077
078   /**
079    * Builder.
080    */
081   public static class Builder {
082      private byte[] signingKey;
083      private int maxCookieBytes = DEFAULT_MAX_COOKIE_BYTES;
084      private Clock clock = Clock.systemUTC();
085
086      /** Constructor. */
087      protected Builder() {}
088
089      /**
090       * Sets the HMAC signing key from a string (UTF-8 encoded).  Required.
091       *
092       * @param value The signing key.  Must be at least 32 bytes (256 bits) when UTF-8 encoded.
093       * @return This object.
094       */
095      public Builder signingKey(String value) {
096         reqnb("value", value);
097         return signingKey(value.getBytes(StandardCharsets.UTF_8));
098      }
099
100      /**
101       * Sets the HMAC signing key bytes.  Required.
102       *
103       * @param value The signing key.  Must be at least 32 bytes (256 bits).
104       * @return This object.
105       */
106      public Builder signingKey(byte[] value) {
107         reqnn("value", value);
108         req(value.length >= 32, "signingKey must be at least 32 bytes (256 bits) for HS256; was %s", value.length);
109         signingKey = cp(value);
110         return this;
111      }
112
113      /**
114       * Sets the maximum serialized cookie size in bytes.
115       *
116       * @param value The cap.  Must be positive.
117       * @return This object.
118       */
119      public Builder maxCookieBytes(int value) {
120         req(value > 0, "maxCookieBytes must be positive (was %s)", value);
121         maxCookieBytes = value;
122         return this;
123      }
124
125      /**
126       * Overrides the clock used for expiry comparisons.  Useful in tests.
127       *
128       * @param value The clock.  Must not be <jk>null</jk>.
129       * @return This object.
130       */
131      public Builder clock(Clock value) {
132         clock = reqnn("value", value);
133         return this;
134      }
135
136      /**
137       * Builds the store.
138       *
139       * @return A new {@link SignedCookieSessionStore}.
140       */
141      public SignedCookieSessionStore build() {
142         if (signingKey == null)
143            throw new IllegalStateException("SignedCookieSessionStore requires signingKey(...)");
144         return new SignedCookieSessionStore(this);
145      }
146   }
147
148   private final byte[] signingKey;
149   private final int maxCookieBytes;
150   private final Clock clock;
151
152   /**
153    * Constructor.
154    *
155    * @param b The builder.
156    */
157   protected SignedCookieSessionStore(Builder b) {
158      this.signingKey = b.signingKey;
159      this.maxCookieBytes = b.maxCookieBytes;
160      this.clock = b.clock;
161   }
162
163   @Override /* Overridden from SessionStore */
164   public String createSessionCookieValue(OidcSession session) {
165      reqnn("session", session);
166      try {
167         var cb = new JWTClaimsSet.Builder()
168            .subject(session.subject())
169            .jwtID(session.id())
170            .issueTime(Date.from(session.createdAt()))
171            .expirationTime(Date.from(session.expiresAt()))
172            .claim(CLAIM_NAME, session.principal().getName())
173            .claim(CLAIM_ROLES, new ArrayList<>(session.roles()))
174            .claim(CLAIM_CLAIMS, session.principal().getClaims());
175         session.sid().ifPresent(sid -> cb.claim(CLAIM_SID, sid));
176         var jwt = new SignedJWT(new JWSHeader(JWSAlgorithm.HS256), cb.build());
177         jwt.sign(new MACSigner(signingKey));
178         var serialized = jwt.serialize();
179         if (serialized.getBytes(StandardCharsets.UTF_8).length > maxCookieBytes)
180            throw isex("Signed session cookie exceeds maxCookieBytes (%s); reduce the claim set or switch to a server-side SessionStore.", maxCookieBytes);
181         return serialized;
182      } catch (JOSEException e) {
183         throw isex(e, "Failed to sign session cookie");
184      }
185   }
186
187   @Override /* Overridden from SessionStore */
188   @SuppressWarnings({
189      "unchecked" // Type erasure on reflective/generic cast; element type is verified at call site
190   })
191   public Optional<OidcSession> lookup(String cookieValue) {
192      reqnn("cookieValue", cookieValue);
193      try {
194         var jwt = SignedJWT.parse(cookieValue);
195         if (! jwt.verify(new MACVerifier(signingKey)))
196            return oe();
197         var claims = jwt.getJWTClaimsSet();
198         var exp = claims.getExpirationTime();
199         if (exp == null || ! clock.instant().isBefore(exp.toInstant()))
200            return oe();
201         var subject = claims.getSubject();
202         var name = claims.getStringClaim(CLAIM_NAME);
203         if (subject == null || name == null)
204            return oe();
205         var roles = new LinkedHashSet<String>();
206         var rolesClaim = claims.getStringListClaim(CLAIM_ROLES);
207         if (rolesClaim != null)
208            roles.addAll(rolesClaim);
209         Map<String,Object> principalClaims = (Map<String,Object>) claims.getClaim(CLAIM_CLAIMS);
210         var principal = new ClaimsPrincipal(name, principalClaims);
211         var sid = o(claims.getStringClaim(CLAIM_SID));
212         var createdAt = claims.getIssueTime() != null ? claims.getIssueTime().toInstant() : clock.instant();
213         return o(new OidcSession(
214            claims.getJWTID(),
215            subject,
216            sid,
217            principal,
218            roles,
219            oe(),
220            createdAt,
221            exp.toInstant()));
222      } catch (java.text.ParseException | JOSEException | RuntimeException e) {
223         return oe();
224      }
225   }
226
227   @Override /* Overridden from SessionStore */
228   public void invalidate(String cookieValue) {
229      // Stateless: nothing to remove server-side.  The caller clears the cookie on the response.
230   }
231
232   @Override /* Overridden from SessionStore */
233   public boolean supportsServerSideRevocation() {
234      return false;
235   }
236}