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> — {@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> — cookies are capped (~4 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> — 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}