Skip to content

Commit 55bb7fe

Browse files
committed
Fix account claims not resolving from account properties and add
required claims
1 parent bf516dc commit 55bb7fe

5 files changed

Lines changed: 108 additions & 12 deletions

File tree

accounts/src/main/java/org/restheart/accounts/util/JwtHelper.java

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,10 @@
99
import org.restheart.plugins.PluginsRegistry;
1010
import org.restheart.security.AuthCookie;
1111
import org.restheart.security.authenticators.MongoRealmAuthenticator;
12+
import org.restheart.security.tokens.JwtConfigProvider;
1213
import org.restheart.security.tokens.JwtIssuer;
14+
import org.slf4j.Logger;
15+
import org.slf4j.LoggerFactory;
1316

1417
import java.time.Instant;
1518
import java.time.temporal.ChronoUnit;
@@ -43,6 +46,8 @@
4346
*/
4447
public class JwtHelper {
4548

49+
private static final Logger LOGGER = LoggerFactory.getLogger(JwtHelper.class);
50+
4651
private final String key;
4752
private final String issuer;
4853
private final int ttlMinutes;
@@ -102,9 +107,37 @@ public JwtHelper(String key, String issuer, int ttlMinutes, List<String> account
102107
issuer,
103108
null,
104109
accountPropertiesClaims,
110+
resolveRequiredClaims(registry),
105111
resolvePasswordPropertyName(registry));
106112
}
107113

114+
/**
115+
* The claims that must survive any per-request override, read from the deployment's existing
116+
* {@code jwtConfigProvider} configuration rather than duplicated under {@code accountsConfig}:
117+
* they are a property of the JWT this deployment issues, so every issuer must apply the same
118+
* ones. Without this, a tenant supplying its own claim list on the {@code /auth/*} path could
119+
* drop a claim later verified on every request and lock itself out.
120+
*/
121+
private static List<String> resolveRequiredClaims(PluginsRegistry registry) {
122+
if (registry == null) {
123+
return null;
124+
}
125+
126+
try {
127+
for (var pr : registry.getProviders()) {
128+
if ("jwtConfigProvider".equals(pr.getName()) && pr.isEnabled()
129+
&& pr.getInstance() instanceof JwtConfigProvider jcp
130+
&& jcp.get(pr) instanceof JwtConfigProvider.JwtConfig cfg) {
131+
return cfg.requiredAccountPropertiesClaims();
132+
}
133+
}
134+
} catch (Exception e) {
135+
LOGGER.debug("Could not resolve jwtConfigProvider required-account-properties-claims", e);
136+
}
137+
138+
return null;
139+
}
140+
108141
/** Risolve il nome della proprietà password da {@code mongoRealmAuthenticator}, se disponibile. */
109142
private static String resolvePasswordPropertyName(PluginsRegistry registry) {
110143
if (registry == null) {

security/src/main/java/org/restheart/security/services/OAuthAuthorizationService.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -148,7 +148,8 @@ private JwtIssuer issuer() {
148148
if (local == null) {
149149
var algo = buildAlgorithm(jwtConfig);
150150
local = new JwtIssuer(algo, jwtConfig.issuer(), jwtConfig.audience(),
151-
jwtConfig.accountPropertiesClaims(), resolvePasswordPropertyName());
151+
jwtConfig.accountPropertiesClaims(),
152+
jwtConfig.requiredAccountPropertiesClaims(), resolvePasswordPropertyName());
152153
this.jwtIssuer = local;
153154
}
154155
}

security/src/main/java/org/restheart/security/tokens/JwtConfigProvider.java

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -129,11 +129,12 @@ public void init() throws ConfigurationException {
129129
// Account properties copied into issued tokens. Shared here so that every issuer —
130130
// jwtTokenManager on /token, restheart-accounts at login — puts the same claims in the
131131
// token: they are the same JWT, issued at different moments.
132-
var accountPropertiesClaims = accountPropertiesClaims(config);
132+
var accountPropertiesClaims = claimList(config, "account-properties-claims");
133+
var requiredAccountPropertiesClaims = claimList(config, "required-account-properties-claims");
133134

134135
this.jwtConfig = new JwtConfig(key, algorithm, issuer,
135136
audience.isEmpty() ? null : audience.toArray(String[]::new),
136-
accountPropertiesClaims);
137+
accountPropertiesClaims, requiredAccountPropertiesClaims);
137138

138139
BootstrapLogger.info(LOGGER, "Algorithm: {}, Issuer: {}, Audience: {}",
139140
algorithm, issuer, audience.isEmpty() ? "null" : String.join(", ", audience));
@@ -158,8 +159,8 @@ public JwtConfig get(PluginRecord<?> caller) {
158159
* @return the configured names, or {@code null} when not set here — issuers then fall back to
159160
* their own deprecated per-plugin setting
160161
*/
161-
private List<String> accountPropertiesClaims(Map<String, Object> config) throws ConfigurationException {
162-
var configured = argOrDefault(config, "account-properties-claims", null);
162+
private List<String> claimList(Map<String, Object> config, String key) throws ConfigurationException {
163+
var configured = argOrDefault(config, key, null);
163164

164165
return switch (configured) {
165166
case null -> null;
@@ -169,7 +170,7 @@ private List<String> accountPropertiesClaims(Map<String, Object> config) throws
169170
.map(e -> (String) e)
170171
.toList();
171172
default -> throw new ConfigurationException(
172-
"Wrong account-properties-claims, must be a String or an Array of Strings");
173+
"Wrong " + key + ", must be a String or an Array of Strings");
173174
};
174175
}
175176

@@ -192,9 +193,14 @@ private String generateSecureRandomKey() {
192193
* same reason {@code key} and {@code issuer} are: a JWT issued by this deployment is one
193194
* thing regardless of which component issues it. {@code null} means "not set here",
194195
* in which case issuers fall back to their own deprecated setting.
196+
* @param requiredAccountPropertiesClaims account properties always copied into issued tokens,
197+
* even when a per-request override supplies its own list. For claims the deployment
198+
* cannot work without — on a multi-tenant node, the claim naming the issuing node is
199+
* verified on every later request, so a tenant able to drop it would lock itself out.
195200
*/
196201
public record JwtConfig(String key, String algorithm, String issuer, String[] audience,
197-
List<String> accountPropertiesClaims) {
202+
List<String> accountPropertiesClaims,
203+
List<String> requiredAccountPropertiesClaims) {
198204
public boolean hasAudience() {
199205
return audience != null && audience.length > 0;
200206
}

security/src/main/java/org/restheart/security/tokens/JwtIssuer.java

Lines changed: 59 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,6 @@
2929
import java.util.Set;
3030

3131
import org.bson.BsonString;
32-
import org.restheart.configuration.Utils;
3332
import org.slf4j.Logger;
3433
import org.slf4j.LoggerFactory;
3534

@@ -110,6 +109,7 @@ public static List<String> claimsOverride(org.restheart.exchange.Request<?> requ
110109
private final String issuer;
111110
private final String[] audience;
112111
private final List<String> defaultClaims;
112+
private final Set<String> requiredClaims;
113113
private final Set<String> denylist;
114114

115115
/**
@@ -124,10 +124,21 @@ public static List<String> claimsOverride(org.restheart.exchange.Request<?> requ
124124
*/
125125
public JwtIssuer(Algorithm algo, String issuer, String[] audience,
126126
List<String> defaultClaims, String passwordProperty) {
127+
this(algo, issuer, audience, defaultClaims, null, passwordProperty);
128+
}
129+
130+
/**
131+
* @param requiredClaims account properties always copied into the token, whatever the effective
132+
* claim list — see {@link #accountClaims(Map, List)}; {@code null} means
133+
* none
134+
*/
135+
public JwtIssuer(Algorithm algo, String issuer, String[] audience,
136+
List<String> defaultClaims, List<String> requiredClaims, String passwordProperty) {
127137
this.algo = algo;
128138
this.issuer = issuer;
129139
this.audience = audience;
130140
this.defaultClaims = defaultClaims;
141+
this.requiredClaims = requiredClaims == null ? Set.of() : Set.copyOf(requiredClaims);
131142

132143
var pwd = passwordProperty == null || passwordProperty.isBlank()
133144
? DEFAULT_PASSWORD_PROPERTY
@@ -159,9 +170,23 @@ public boolean isDenylisted(String claim) {
159170
public Map<String, Object> accountClaims(Map<String, ? super Object> properties, List<String> claimsOverride) {
160171
final var ret = new HashMap<String, Object>();
161172

162-
var claims = claimsOverride != null ? claimsOverride : defaultClaims;
173+
if (properties == null) {
174+
return ret;
175+
}
176+
177+
var configured = claimsOverride != null ? claimsOverride : defaultClaims;
163178

164-
if (claims == null || properties == null) {
179+
// Required claims are added whatever the effective list says. They are infrastructure the
180+
// deployment depends on — e.g. on a multi-tenant node a claim naming the node that issued
181+
// the token, checked on every subsequent request — so a tenant supplying its own list must
182+
// not be able to drop them and lock itself out. The denylist still wins over this.
183+
var claims = new java.util.LinkedHashSet<String>();
184+
claims.addAll(requiredClaims);
185+
if (configured != null) {
186+
claims.addAll(configured);
187+
}
188+
189+
if (claims.isEmpty()) {
165190
return ret;
166191
}
167192

@@ -177,7 +202,7 @@ public Map<String, Object> accountClaims(Map<String, ? super Object> properties,
177202
continue;
178203
}
179204

180-
var value = Utils.find(properties, path, true);
205+
var value = valueAt(properties, keys);
181206

182207
if (value != null) {
183208
addClaim(ret, keys, value);
@@ -302,6 +327,36 @@ public Builder withClaim(final Builder b, final String k, final Object v) {
302327
};
303328
}
304329

330+
/**
331+
* Navigates {@code properties} following {@code keys}, returning the value found or
332+
* {@code null}.
333+
*
334+
* <p>Deliberately a plain map walk rather than {@code Utils.find}'s JXPath: that helper is
335+
* meant for the YAML configuration tree, and it resolves against account properties only for
336+
* some account types — {@code FileRealmAccount} hands back the map it parsed, whereas
337+
* {@code MongoRealmAccount} rebuilds it through GSON. Worse, it swallows every failure
338+
* ({@code catch (Throwable)} with {@code silent = true}), so a claim silently vanished from
339+
* the token with nothing in the logs. A map lookup has none of those problems and is what
340+
* this actually needs.
341+
*/
342+
static Object valueAt(Map<String, ? super Object> properties, String[] keys) {
343+
Object current = properties;
344+
345+
for (var key : keys) {
346+
if (!(current instanceof Map<?, ?> map)) {
347+
return null;
348+
}
349+
350+
current = map.get(key);
351+
352+
if (current == null) {
353+
return null;
354+
}
355+
}
356+
357+
return current;
358+
}
359+
305360
/** Splits {@code a/b/c} into its keys, dropping empty segments. */
306361
static String[] keysFromPath(final String path) {
307362
var ret = path.contains("/") ? path.split("/") : new String[]{path};

security/src/main/java/org/restheart/security/tokens/JwtTokenManager.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -191,7 +191,8 @@ JwtIssuer issuer() {
191191
synchronized (this) {
192192
local = this.issuerImpl;
193193
if (local == null) {
194-
local = new JwtIssuer(algo, issuer, audience, accountPropertiesClaims, passwordProperty());
194+
local = new JwtIssuer(algo, issuer, audience, accountPropertiesClaims,
195+
jwtConfig.requiredAccountPropertiesClaims(), passwordProperty());
195196
this.issuerImpl = local;
196197
}
197198
}

0 commit comments

Comments
 (0)