Skip to content
This repository was archived by the owner on May 21, 2026. It is now read-only.

Commit b0f76ff

Browse files
Refactor: Stateless JWT auth — AccessToken in payload + RefreshToken as HttpOnly cookie (#44)
* Initial plan * Refactor: fully stateless JWT auth with AccessToken + RefreshToken cookie Co-authored-by: vitorhugo-java <65777252+vitorhugo-java@users.noreply.github.com> * Fix review comments: remove legacy auth cookie code, clarify docs, remove unused param Co-authored-by: vitorhugo-java <65777252+vitorhugo-java@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: vitorhugo-java <65777252+vitorhugo-java@users.noreply.github.com>
1 parent f678c7a commit b0f76ff

15 files changed

Lines changed: 454 additions & 337 deletions

File tree

Lines changed: 38 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
package com.espacogeek.geek.config;
22

33
import java.net.URI;
4-
import java.util.Map;
4+
import java.util.ArrayList;
5+
import java.util.List;
56

67
import org.springframework.context.annotation.Configuration;
78
import org.springframework.graphql.server.WebGraphQlInterceptor;
@@ -10,12 +11,25 @@
1011
import org.springframework.http.HttpHeaders;
1112
import org.springframework.http.ResponseCookie;
1213
import org.springframework.lang.NonNull;
13-
import org.springframework.util.StringUtils;
1414

1515
import reactor.core.publisher.Mono;
1616

1717
/**
18-
* Interceptor to set/clear HttpOnly auth cookie after login/logout GraphQL operations.
18+
* Interceptor that manages {@code refreshToken} HttpOnly cookie operations for login,
19+
* refreshToken, and logout GraphQL mutations.
20+
*
21+
* <p>Before execution begins, this interceptor injects two shared containers into the
22+
* {@link graphql.GraphQLContext}:</p>
23+
* <ul>
24+
* <li>{@code "pendingRefreshTokens"} – controller methods add a refresh token here when
25+
* one should be set as an HttpOnly cookie on the response.</li>
26+
* <li>{@code "clearRefreshCookieHolder"} – a {@code boolean[1]} array; controller methods
27+
* set index {@code [0]} to {@code true} to signal that the refresh token cookie should
28+
* be cleared (e.g., on logout).</li>
29+
* </ul>
30+
* <p>After execution completes, this interceptor reads the containers and writes the
31+
* appropriate {@code Set-Cookie} header to the HTTP response via
32+
* {@link WebGraphQlResponse#getResponseHeaders()}.</p>
1933
*/
2034
@Configuration
2135
public class GraphQlCookieInterceptor implements WebGraphQlInterceptor {
@@ -26,54 +40,34 @@ public GraphQlCookieInterceptor(JwtConfig jwtConfig) {
2640
this.jwtConfig = jwtConfig;
2741
}
2842

29-
@SuppressWarnings("null")
3043
@Override
3144
public @NonNull Mono<WebGraphQlResponse> intercept(@NonNull WebGraphQlRequest request, @NonNull Chain chain) {
45+
// Shared containers — populated by controller methods via GraphQLContext during execution
46+
List<String> pendingRefreshTokens = new ArrayList<>();
47+
boolean[] clearRefreshCookieHolder = {false};
48+
49+
request.configureExecutionInput((input, builder) ->
50+
builder.graphQLContext(ctx -> {
51+
ctx.put("pendingRefreshTokens", pendingRefreshTokens);
52+
ctx.put("clearRefreshCookieHolder", clearRefreshCookieHolder);
53+
}).build());
54+
3255
return chain.next(request).map(response -> {
33-
String operationName = request.getOperationName();
34-
if (!StringUtils.hasText(operationName)) {
35-
operationName = extractTopFieldName(request.getDocument());
36-
}
56+
String origin = request.getHeaders().getFirst(HttpHeaders.ORIGIN);
57+
URI serverUri = request.getUri().toUri();
3758

38-
if (operationName != null) {
39-
URI serverUri = request.getUri().toUri();
40-
String origin = request.getHeaders().getFirst(HttpHeaders.ORIGIN);
59+
if (!pendingRefreshTokens.isEmpty()) {
60+
ResponseCookie cookie = jwtConfig.buildRefreshTokenCookie(
61+
pendingRefreshTokens.get(0), origin, serverUri);
62+
response.getResponseHeaders().add(HttpHeaders.SET_COOKIE, cookie.toString());
63+
}
4164

42-
Map<String, Object> data = response.getData();
43-
if ("login".equals(operationName.toLowerCase())) {
44-
if (data != null) {
45-
Object val = data.get("login");
46-
if (val instanceof String token && !token.isBlank()) {
47-
ResponseCookie cookie = jwtConfig.buildAuthCookie(token, origin, serverUri);
48-
response.getResponseHeaders().add(HttpHeaders.SET_COOKIE, cookie.toString());
49-
}
50-
}
51-
} else if ("logout".equals(operationName.toLowerCase())) {
52-
ResponseCookie clear = jwtConfig.clearAuthCookie(origin, serverUri);
53-
response.getResponseHeaders().add(HttpHeaders.SET_COOKIE, clear.toString());
54-
}
65+
if (clearRefreshCookieHolder[0]) {
66+
ResponseCookie clear = jwtConfig.clearRefreshTokenCookie(origin, serverUri);
67+
response.getResponseHeaders().add(HttpHeaders.SET_COOKIE, clear.toString());
5568
}
69+
5670
return response;
5771
});
5872
}
59-
60-
private String extractTopFieldName(String document) {
61-
if (document == null) return null;
62-
String s = document.stripLeading();
63-
int idx = s.indexOf('{');
64-
if (idx >= 0) {
65-
String after = s.substring(idx + 1).trim();
66-
int end = after.indexOf('(');
67-
int space = after.indexOf(' ');
68-
int brace = after.indexOf('}');
69-
int cut = Integer.MAX_VALUE;
70-
if (end >= 0) cut = Math.min(cut, end);
71-
if (space >= 0) cut = Math.min(cut, space);
72-
if (brace >= 0) cut = Math.min(cut, brace);
73-
if (cut != Integer.MAX_VALUE) {
74-
return after.substring(0, cut).trim();
75-
}
76-
}
77-
return null;
78-
}
7973
}

src/main/java/com/espacogeek/geek/config/JwtConfig.java

Lines changed: 89 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -33,22 +33,29 @@ public class JwtConfig {
3333
@Value("${security.jwt.secret:ZmFrZS1zZWNyZXQtZmFrZS1zZWNyZXQtZmFrZS1zZWNyZXQtMTIzNDU2}")
3434
private String secret;
3535

36+
/** Long-lived refresh token expiry (default 7 days). Used for the refreshToken cookie. */
3637
@Value("${security.jwt.expiration-ms:604800000}")
3738
private long expirationMs;
3839

40+
/** Short-lived access token expiry (default 15 minutes). Returned in the JSON payload. */
41+
@Value("${security.jwt.access-token-expiration-ms:900000}")
42+
private long accessTokenExpirationMs;
43+
3944
@Value("${security.jwt.issuer:espaco-geek}")
4045
private String issuer;
4146

42-
// Cookie settings
43-
@Value("${security.jwt.cookie-name:EG_AUTH}")
44-
private String cookieName;
45-
47+
// Cookie shared settings (path, domain, SameSite) used for both the refresh token cookie
48+
// and any other cookie-related operations. The legacy EG_AUTH cookie is no longer used.
4649
@Value("${security.jwt.cookie-path:/}")
4750
private String cookiePath;
4851

4952
@Value("${security.jwt.cookie-domain:}")
5053
private String cookieDomain;
5154

55+
// Refresh token cookie settings
56+
@Value("${security.jwt.refresh-token-cookie-name:refreshToken}")
57+
private String refreshTokenCookieName;
58+
5259
// When request is same-site, which SameSite to use (Lax or Strict). Default Lax.
5360
@Value("${security.jwt.same-site-when-same-site:Lax}")
5461
private String sameSiteWhenSameSite;
@@ -64,19 +71,13 @@ private SecretKey getSigningKey() {
6471
}
6572

6673
/**
67-
* Generate a signed JWT for the given user.
68-
* @param user the authenticated user
69-
* @return compact JWT string
74+
* Build the normalized roles list for a user.
7075
*/
71-
public String generateToken(UserModel user) {
72-
Instant now = Instant.now();
73-
74-
// Build roles list from user.userRole (comma separated) and ensure ID_ claim is included
76+
private List<String> buildRolesList(UserModel user) {
7577
List<String> rolesList = new ArrayList<>();
7678
String raw = user.getUserRole();
7779
if (raw != null && !raw.isBlank()) {
7880
String[] parts = raw.replaceAll("\\s", "").split(",");
79-
// Normalize roles: if a role doesn't start with ROLE_ or ID_, prefix with ROLE_
8081
rolesList.addAll(Arrays.stream(parts)
8182
.map(s -> s == null ? null : s.trim())
8283
.filter(s -> s != null && !s.isBlank())
@@ -86,24 +87,68 @@ public String generateToken(UserModel user) {
8687
})
8788
.toList());
8889
}
89-
// Ensure at least ROLE_user is present as a fallback
9090
if (rolesList.isEmpty()) {
9191
rolesList.add("ROLE_user");
9292
}
93-
// Add device/user identifier role (keep ID_ as-is)
9493
rolesList.add("ID_" + user.getId());
94+
return rolesList;
95+
}
96+
97+
/**
98+
* Generate a short-lived access token (15 min by default) for the given user.
99+
* The token carries {@code type=access} and is intended to be returned in the JSON payload.
100+
*
101+
* @param user the authenticated user
102+
* @return compact JWT string
103+
*/
104+
public String generateAccessToken(UserModel user) {
105+
Instant now = Instant.now();
106+
return Jwts.builder()
107+
.issuer(issuer)
108+
.subject(user.getEmail())
109+
.issuedAt(Date.from(now))
110+
.expiration(Date.from(now.plusMillis(accessTokenExpirationMs)))
111+
.claim("uid", user.getId())
112+
.claim("roles", buildRolesList(user))
113+
.claim("type", "access")
114+
.signWith(getSigningKey(), Jwts.SIG.HS256)
115+
.compact();
116+
}
95117

118+
/**
119+
* Generate a long-lived refresh token (7 days by default) for the given user.
120+
* The token carries {@code type=refresh} and is stored in the database; it is
121+
* delivered to the client via an HttpOnly cookie named {@code refreshToken}.
122+
*
123+
* @param user the authenticated user
124+
* @return compact JWT string
125+
*/
126+
public String generateRefreshToken(UserModel user) {
127+
Instant now = Instant.now();
96128
return Jwts.builder()
97129
.issuer(issuer)
98130
.subject(user.getEmail())
99131
.issuedAt(Date.from(now))
100132
.expiration(Date.from(now.plusMillis(expirationMs)))
101133
.claim("uid", user.getId())
102-
.claim("roles", rolesList)
134+
.claim("roles", buildRolesList(user))
135+
.claim("type", "refresh")
103136
.signWith(getSigningKey(), Jwts.SIG.HS256)
104137
.compact();
105138
}
106139

140+
/**
141+
* Generate a signed JWT access token for the given user.
142+
* Delegates to {@link #generateAccessToken(UserModel)}.
143+
* Retained for backward compatibility with test code that calls this method directly.
144+
*
145+
* @param user the authenticated user
146+
* @return compact JWT string
147+
*/
148+
public String generateToken(UserModel user) {
149+
return generateAccessToken(user);
150+
}
151+
107152
/**
108153
* Validate a token and return its claims if valid.
109154
* @param token JWT string
@@ -130,46 +175,47 @@ public boolean isValid(String token) {
130175
}
131176

132177
/**
133-
* Get the name of the auth cookie for clients.
178+
* Get the name of the refresh token HttpOnly cookie.
134179
*/
135-
public String cookieName() {
136-
return cookieName;
180+
public String refreshTokenCookieName() {
181+
return refreshTokenCookieName;
137182
}
138183

139184
/**
140-
* Build the Set-Cookie for the auth token with HttpOnly; Secure; Path=/ and appropriate SameSite.
141-
* - If different site (domain/port/scheme) from backend: SameSite=None; Secure
142-
* - If same site: SameSite=Lax/Strict based on configuration
185+
* Build the HttpOnly {@code refreshToken} Set-Cookie using the request for SameSite/Secure detection.
186+
* The cookie is scoped to Path=/ so it is sent to the server on every request,
187+
* but since only the {@code refreshToken} mutation reads it, it is safe.
143188
*/
144-
public ResponseCookie buildAuthCookie(String token, HttpServletRequest request) {
189+
public ResponseCookie buildRefreshTokenCookie(String token, HttpServletRequest request) {
145190
boolean crossSite = isCrossSite(request);
146191
String sameSite = crossSite ? "None" : normalizeSameSite(sameSiteWhenSameSite);
147192
boolean secure = crossSite || request.isSecure();
148193

149-
ResponseCookie.ResponseCookieBuilder builder = ResponseCookie.from(cookieName, token)
194+
ResponseCookie.ResponseCookieBuilder builder = ResponseCookie.from(refreshTokenCookieName, token)
150195
.httpOnly(true)
151196
.secure(secure)
152197
.path(cookiePath)
153198
.maxAge(Duration.ofMillis(expirationMs))
154199
.sameSite(sameSite);
155-
156200
if (cookieDomain != null && !cookieDomain.isBlank()) {
157201
builder.domain(cookieDomain);
158202
}
159203
return builder.build();
160204
}
161205

162-
/** Build auth cookie using Origin header and server URI (for GraphQL interceptor). */
163-
public ResponseCookie buildAuthCookie(String token, String originHeader, URI serverUri) {
164-
boolean crossSite = isCrossSite(originHeader, serverUri);
206+
/**
207+
* Clear the HttpOnly {@code refreshToken} cookie.
208+
*/
209+
public ResponseCookie clearRefreshTokenCookie(HttpServletRequest request) {
210+
boolean crossSite = isCrossSite(request);
165211
String sameSite = crossSite ? "None" : normalizeSameSite(sameSiteWhenSameSite);
166-
boolean secure = crossSite || "https".equalsIgnoreCase(serverUri.getScheme());
212+
boolean secure = crossSite || request.isSecure();
167213

168-
ResponseCookie.ResponseCookieBuilder builder = ResponseCookie.from(cookieName, token)
214+
ResponseCookie.ResponseCookieBuilder builder = ResponseCookie.from(refreshTokenCookieName, "")
169215
.httpOnly(true)
170216
.secure(secure)
171217
.path(cookiePath)
172-
.maxAge(Duration.ofMillis(expirationMs))
218+
.maxAge(Duration.ZERO)
173219
.sameSite(sameSite);
174220
if (cookieDomain != null && !cookieDomain.isBlank()) {
175221
builder.domain(cookieDomain);
@@ -178,32 +224,36 @@ public ResponseCookie buildAuthCookie(String token, String originHeader, URI ser
178224
}
179225

180226
/**
181-
* Build a Set-Cookie header that clears the auth cookie.
227+
* Build the HttpOnly {@code refreshToken} Set-Cookie using Origin/server URI for SameSite/Secure detection.
228+
* Used by {@link com.espacogeek.geek.config.GraphQlCookieInterceptor} which has access to
229+
* the {@code WebGraphQlRequest} headers and URI but not to the raw {@code HttpServletRequest}.
182230
*/
183-
public ResponseCookie clearAuthCookie(HttpServletRequest request) {
184-
boolean crossSite = isCrossSite(request);
231+
public ResponseCookie buildRefreshTokenCookie(String token, String originHeader, URI serverUri) {
232+
boolean crossSite = isCrossSite(originHeader, serverUri);
185233
String sameSite = crossSite ? "None" : normalizeSameSite(sameSiteWhenSameSite);
186-
boolean secure = crossSite || request.isSecure();
234+
boolean secure = crossSite || "https".equalsIgnoreCase(serverUri.getScheme());
187235

188-
ResponseCookie.ResponseCookieBuilder builder = ResponseCookie.from(cookieName, "")
236+
ResponseCookie.ResponseCookieBuilder builder = ResponseCookie.from(refreshTokenCookieName, token)
189237
.httpOnly(true)
190238
.secure(secure)
191239
.path(cookiePath)
192-
.maxAge(Duration.ZERO)
240+
.maxAge(Duration.ofMillis(expirationMs))
193241
.sameSite(sameSite);
194242
if (cookieDomain != null && !cookieDomain.isBlank()) {
195243
builder.domain(cookieDomain);
196244
}
197245
return builder.build();
198246
}
199247

200-
/** Clear auth cookie using Origin header and server URI (for GraphQL interceptor). */
201-
public ResponseCookie clearAuthCookie(String originHeader, URI serverUri) {
248+
/**
249+
* Clear the HttpOnly {@code refreshToken} cookie using Origin/server URI for SameSite/Secure detection.
250+
*/
251+
public ResponseCookie clearRefreshTokenCookie(String originHeader, URI serverUri) {
202252
boolean crossSite = isCrossSite(originHeader, serverUri);
203253
String sameSite = crossSite ? "None" : normalizeSameSite(sameSiteWhenSameSite);
204254
boolean secure = crossSite || "https".equalsIgnoreCase(serverUri.getScheme());
205255

206-
ResponseCookie.ResponseCookieBuilder builder = ResponseCookie.from(cookieName, "")
256+
ResponseCookie.ResponseCookieBuilder builder = ResponseCookie.from(refreshTokenCookieName, "")
207257
.httpOnly(true)
208258
.secure(secure)
209259
.path(cookiePath)

0 commit comments

Comments
 (0)