Skip to content

Commit cfc6cef

Browse files
herglotzmarcomarco.herglotz
andauthored
Allow configuring max age and expiry notifications for login tokens (#28)
* Allow configuring max age and expiry notifications for login tokens - Trying to enforce maximum security, access tokens should not be valid forever. - It is possible to implement automatic token rotation by directly accessing the REST service similar to what the UI does. - Enabling max token age invalidation (which is optional and off by default for backwards compatibility) means a lot more invalidation happen. - To help users troubleshoot 401 errors when contacting nexus, a possibility to notify users via email when their tokens expire was introduced. - This is entirely optional and will only work if Nexus has a mail server configured via default Sonatype means. - Leveraging OAuth2 Proxys behaviour and correct reverse proxy configuration, this allows to use API tokens like OIDC access tokens with very limited lifespan, where renewing the API token requires full OIDC login, potentially including 2FA, Passkeys or whatever login method is configured. - If everything is configured correctly, it is not possible to renew the API token with an existing API token, but only with full OAuth2 Proxy login, further strengthening security * Adjust README to explain the new possibilites --------- Co-authored-by: marco.herglotz <marco.herglotz@cas.de>
1 parent 9679008 commit cfc6cef

11 files changed

+366
-66
lines changed

docs/README.md

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,8 +24,12 @@ It is important to highlight that this plugin is provided on an 'as-is' basis, w
2424
* the groups received in the related header will be stored in a dedicated database table and become available for the 'external role mapping' functionality
2525
* ⚠️ note: [currently it is necessary](https://github.com/tumbl3w33d/nexus-oauth2-proxy-plugin/issues/26) to use this mapping mechanism because assigning Nexus' default roles to users created via plugin has no effect
2626
* automatic expiry of API tokens
27-
* there is a configurable task that lets API tokens expire, so another login by the user is necessary to renew it
28-
* as long as the user keeps showing up regularly, their token will not expire
27+
* there is a configurable task that lets API tokens expire, so another login and manual renewal by the user is necessary
28+
* by default, the token will expire after 30 days of inactivity. As long as the user keeps showing up regularly, their token will not expire
29+
* The expiration can be configured as a regular nexus task. You can:
30+
* adjust inactivity period that leads to token invalidation
31+
* enable and set max token age, meaning the token automatically expires after a certain time, regardless of activity
32+
* enable mail notifications on token expiry (requires having a mail server configured in Nexus to work)
2933
* backchannel logout in IDP via oauth2 proxy (if supported) when the logout is performed in Nexus
3034
* make sure to enable the OAuth2 Proxy: Logout capability in Nexus to make this work
3135

@@ -168,6 +172,14 @@ skip_provider_button = true
168172

169173
**Note**: Depending on the amount of data the OAuth2 Proxy receives from your IDP (especially list of groups) you might want to look into [changing its session storage to redis/valkey](https://oauth2-proxy.github.io/oauth2-proxy/configuration/session_storage/#redis-storage). The proxy will warn you about data exceeding limits which results in multiple cookies being set for the proxy session.
170174

175+
## Optional: Bearer token authentication and token rotation
176+
177+
If for some reason you need to use a Bearer token for machine-to-machine communcation or in general accessing Nexus programmatically e.g. because corporate guidelines prevent you from using Basic Auth using the api token, it is possible to set this up:
178+
179+
Add 'skip_jwt_bearer_tokens = true' to your OAuth2 Proxy configuration. This flag makes OAuth2 Proxy optionally accept Bearer tokens instead of performing the auth flow itself as long as the Bearer token is valid and the audience matches the configured client id. For details, check the [official documentation](https://oauth2-proxy.github.io/oauth2-proxy/configuration/overview/). OAuth2 Proxy will then populate the x-forwarded headers based on information from this token, so for Nexus the login mechanism is still transparent.
180+
181+
Leveraging this way of authenticating it is possible to create an automatic token rotation even if the API token is invalidated by obtaining the Bearer token via some kind of OIDC login and then using it to perform an authenticated REST call against https://your_nexus_host/service/rest/oauth2-proxy/user/reset-token to obtain a new API token. Keep in mind that this REST call immediately invalidates the old token! Also make sure your reverse proxy is configured to route this URL to Nexus via OAuth2 Proxy (if you use the above example configs, this should automatically be the case)
182+
171183
## Troubleshooting
172184

173185
If you encounter authentication issues, you can activate logging for the plugin classes by creating a logger in the Nexus administration section (`Support -> Logging -> Create Logger`), e.g. for the top level package `com.github.tumbl3w33d`.

src/main/java/com/github/tumbl3w33d/OAuth2ProxyApiTokenInvalidateTask.java

Lines changed: 113 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -5,88 +5,164 @@
55
import java.sql.Timestamp;
66
import java.time.Instant;
77
import java.time.temporal.ChronoUnit;
8-
import java.util.List;
8+
import java.util.HashSet;
9+
import java.util.Map;
910
import java.util.Set;
1011

1112
import javax.inject.Inject;
1213
import javax.inject.Named;
1314

15+
import org.apache.commons.mail.EmailException;
16+
import org.apache.commons.mail.SimpleEmail;
17+
import org.sonatype.nexus.common.app.BaseUrlHolder;
18+
import org.sonatype.nexus.email.EmailManager;
1419
import org.sonatype.nexus.logging.task.TaskLogging;
1520
import org.sonatype.nexus.scheduling.Cancelable;
1621
import org.sonatype.nexus.scheduling.TaskSupport;
17-
import org.sonatype.nexus.security.user.UserManager;
22+
import org.sonatype.nexus.security.SecuritySystem;
23+
import org.sonatype.nexus.security.user.User;
1824
import org.sonatype.nexus.security.user.UserNotFoundException;
1925

2026
import com.github.tumbl3w33d.h2.OAuth2ProxyLoginRecordStore;
27+
import com.github.tumbl3w33d.h2.OAuth2ProxyTokenInfoStore;
2128
import com.github.tumbl3w33d.users.OAuth2ProxyUserManager;
2229
import com.github.tumbl3w33d.users.db.OAuth2ProxyLoginRecord;
30+
import com.github.tumbl3w33d.users.db.OAuth2ProxyTokenInfo;
2331

2432
@Named
2533
@TaskLogging(NEXUS_LOG_ONLY)
2634
public class OAuth2ProxyApiTokenInvalidateTask extends TaskSupport implements Cancelable {
2735

2836
private final OAuth2ProxyLoginRecordStore loginRecordStore;
29-
37+
private final OAuth2ProxyTokenInfoStore tokenInfoStore;
3038
private final OAuth2ProxyUserManager userManager;
39+
private final SecuritySystem securitySystem;
40+
private final EmailManager mailManager;
3141

3242
@Inject
3343
public OAuth2ProxyApiTokenInvalidateTask(@Named OAuth2ProxyLoginRecordStore loginRecordStore,
34-
final List<UserManager> userManagers, final OAuth2ProxyUserManager userManager) {
35-
44+
@Named OAuth2ProxyTokenInfoStore tokenInfoStore, @Named OAuth2ProxyUserManager userManager,
45+
SecuritySystem securitySystem, EmailManager mailManager) {
3646
this.loginRecordStore = loginRecordStore;
47+
this.tokenInfoStore = tokenInfoStore;
3748
this.userManager = userManager;
38-
}
39-
40-
private void resetApiToken(String userId) {
41-
try {
42-
userManager.changePassword(userId, OAuth2ProxyRealm.generateSecureRandomString(32));
43-
log.debug("API token reset for user {} succeeded", userId);
44-
} catch (UserNotFoundException e) {
45-
log.error("Unable to reset API token of user {} - {}", userId, e);
46-
}
49+
this.securitySystem = securitySystem;
50+
this.mailManager = mailManager;
4751
}
4852

4953
@Override
5054
protected Void execute() throws Exception {
55+
Map<String, OAuth2ProxyLoginRecord> loginRecords = loginRecordStore.getAllLoginRecords();
56+
Map<String, OAuth2ProxyTokenInfo> tokenInfos = tokenInfoStore.getAllTokenInfos();
5157

52-
Set<OAuth2ProxyLoginRecord> loginRecords = loginRecordStore.getAllLoginRecords();
53-
54-
if (loginRecords.isEmpty()) {
55-
log.debug("No login records found, nothing to do");
58+
if (loginRecords.isEmpty() && tokenInfos.isEmpty()) {
59+
log.debug("No records found, nothing to do");
5660
return null;
5761
}
5862

59-
for (OAuth2ProxyLoginRecord loginRecord : loginRecords) {
60-
String userId = loginRecord.getId();
61-
Timestamp lastLoginDate = loginRecord.getLastLogin();
62-
63-
Instant lastLoginInstant = lastLoginDate.toInstant();
64-
Instant nowInstant = Instant.now();
63+
int configuredIdleExpiration = getConfiguration().getInteger(
64+
OAuth2ProxyApiTokenInvalidateTaskDescriptor.CONFIG_IDLE_EXPIRY,
65+
OAuth2ProxyApiTokenInvalidateTaskDescriptor.CONFIG_IDLE_EXPIRY_DEFAULT);
66+
67+
int configuredMaxTokenAge = getConfiguration().getInteger(
68+
OAuth2ProxyApiTokenInvalidateTaskDescriptor.CONFIG_AGE,
69+
OAuth2ProxyApiTokenInvalidateTaskDescriptor.CONFIG_AGE_DEFAULT);
70+
71+
boolean notify = getConfiguration().getBoolean(OAuth2ProxyApiTokenInvalidateTaskDescriptor.NOTIFY,
72+
OAuth2ProxyApiTokenInvalidateTaskDescriptor.NOTIFY_DEFAULT);
73+
74+
Set<String> userIds = new HashSet<>(loginRecords.size());
75+
userIds.addAll(loginRecords.keySet());
76+
userIds.addAll(tokenInfos.keySet());
77+
for (String userId : userIds) {
78+
if ("admin".equals(userId)) {
79+
// never reset the admin "token" as it would overwrite the password, possibly locking people out of nexus
80+
// when the task would run before OIDC setup is completed
81+
continue;
82+
}
6583

66-
log.debug("Last known login for {} was {}", userId,
67-
OAuth2ProxyRealm.formatDateString(lastLoginDate));
84+
if (isUserIdleTimeExpired(userId, loginRecords.get(userId), configuredIdleExpiration)
85+
|| isTokenLifespanExpired(userId, tokenInfos.get(userId), configuredMaxTokenAge)) {
86+
resetApiToken(userId, notify);
87+
log.info("API token of user {} has been reset", userId);
88+
}
6889

69-
long timePassed = ChronoUnit.DAYS.between(lastLoginInstant, nowInstant);
90+
}
91+
return null;
92+
}
7093

71-
int configuredDuration = getConfiguration()
72-
.getInteger(OAuth2ProxyApiTokenInvalidateTaskDescriptor.CONFIG_EXPIRY, 1);
94+
private boolean isUserIdleTimeExpired(String userId, OAuth2ProxyLoginRecord loginRecord, int configuredIdleTime) {
95+
if (configuredIdleTime <= 0) {
96+
return false;
97+
}
7398

74-
log.debug("Time passed since login: {} - configured maximum: {}", timePassed,
75-
configuredDuration);
99+
Timestamp lastLoginDate = loginRecord.getLastLogin();
100+
log.debug("Last known login for {} was {}", userId, OAuth2ProxyRealm.formatDateString(lastLoginDate));
101+
long timePassed = ChronoUnit.DAYS.between(lastLoginDate.toInstant(), Instant.now());
102+
log.debug("Time passed since login: {} - configured maximum: {}", timePassed, configuredIdleTime);
103+
if (timePassed >= configuredIdleTime) {
104+
log.debug("Idle time expired for {}", userId);
105+
return true;
106+
}
107+
return false;
108+
}
76109

77-
if (timePassed >= configuredDuration) {
78-
resetApiToken(userId);
79-
log.info("Reset api token of user {} because they did not show up for a while",
80-
userId);
81-
}
110+
private boolean isTokenLifespanExpired(String userId, OAuth2ProxyTokenInfo tokenInfo, int configuredMaxTokenAge) {
111+
if (configuredMaxTokenAge <= 0) {
112+
return false;
82113
}
83114

84-
return null;
115+
Timestamp tokenCreationDate = tokenInfo.getTokenCreation();
116+
log.debug("API token for {} was created at {}", userId, OAuth2ProxyRealm.formatDateString(tokenCreationDate));
117+
long timePassed = ChronoUnit.DAYS.between(tokenCreationDate.toInstant(), Instant.now());
118+
log.debug("Time passed since token creation: {} - configured maximum: {}", timePassed, configuredMaxTokenAge);
119+
if (timePassed >= configuredMaxTokenAge) {
120+
log.debug("Token lifespan expired for user {}", userId);
121+
return true;
122+
}
123+
return false;
85124
}
86125

87126
@Override
88127
public String getMessage() {
89128
return "Invalidate OAuth2 Proxy API tokens of users who did not show up for a while";
90129
}
91130

131+
private void resetApiToken(String userId, boolean notify) {
132+
try {
133+
securitySystem.changePassword(userId, OAuth2ProxyRealm.generateSecureRandomString(32));
134+
log.debug("API token reset for user {} succeeded", userId);
135+
if (notify) {
136+
sendMail(userId);
137+
}
138+
} catch (UserNotFoundException e) {
139+
log.error("Unable to reset API token of user {}", userId);
140+
log.debug("Unable to reset API token of user {}", userId, e);
141+
}
142+
}
143+
144+
private void sendMail(String userId) throws UserNotFoundException {
145+
if (mailManager.getConfiguration().isEnabled()) {
146+
User user = userManager.getUser(userId);
147+
String to = user.getEmailAddress();
148+
try {
149+
SimpleEmail mail = new SimpleEmail();
150+
mail.addTo(to);
151+
if (BaseUrlHolder.isSet()) {
152+
mail.setMsg("Your OAuth2 Proxy API Token on " + BaseUrlHolder.get()
153+
+ " has been invalidated because of inactivity or expired token lifespan");
154+
} else {
155+
mail.setMsg(
156+
"Your OAuth2 Proxy API Token has been invalidated because of inactivity or expired token lifespan");
157+
}
158+
mailManager.send(mail);
159+
} catch (EmailException e) {
160+
log.warn("Failed to send notification email about oauth2 API token reset to user " + user.getName());
161+
log.debug("Failed to send notification email", e);
162+
}
163+
} else {
164+
log.warn("Sending token invalidation notifications is enabled, but no mail server is configured in Nexus");
165+
}
166+
}
167+
92168
}

src/main/java/com/github/tumbl3w33d/OAuth2ProxyApiTokenInvalidateTaskDescriptor.java

Lines changed: 31 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -5,29 +5,51 @@
55
import javax.inject.Singleton;
66

77
import org.sonatype.nexus.common.upgrade.AvailabilityVersion;
8+
import org.sonatype.nexus.formfields.CheckboxFormField;
89
import org.sonatype.nexus.formfields.FormField;
910
import org.sonatype.nexus.formfields.NumberTextFormField;
1011
import org.sonatype.nexus.scheduling.TaskDescriptorSupport;
1112

1213
@AvailabilityVersion(from = "1.0")
1314
@Named
1415
@Singleton
15-
public class OAuth2ProxyApiTokenInvalidateTaskDescriptor
16-
extends TaskDescriptorSupport {
16+
public class OAuth2ProxyApiTokenInvalidateTaskDescriptor extends TaskDescriptorSupport {
1717

1818
public static final String TYPE_ID = "oauth2-proxy-api-token.cleanup";
1919

20-
public static final String CONFIG_EXPIRY = TYPE_ID + "-expiry";
21-
private static final NumberTextFormField field = new NumberTextFormField(CONFIG_EXPIRY,
22-
"Expiration in days",
23-
"After this duration the API token will be overwritten and the user must renew it interactively.",
24-
FormField.MANDATORY).withMinimumValue(1).withInitialValue(30);
20+
public static final String CONFIG_IDLE_EXPIRY = TYPE_ID + "-expiry";
21+
public static final int CONFIG_IDLE_EXPIRY_DEFAULT = 30;
22+
private static final NumberTextFormField maxIdleAge = new NumberTextFormField(CONFIG_IDLE_EXPIRY, //
23+
"User idle time in days", //
24+
"After the user has been inactive for this amount of days the API token will be overwritten and the user must renew it interactively. Setting this to 0 or a negative value disables max idle time entirely. Default is "
25+
+ CONFIG_IDLE_EXPIRY_DEFAULT + " days.",
26+
FormField.MANDATORY)//
27+
.withMinimumValue(1)//
28+
.withInitialValue(CONFIG_IDLE_EXPIRY_DEFAULT);
29+
30+
public static final String CONFIG_AGE = TYPE_ID + "-max-age";
31+
public static final int CONFIG_AGE_DEFAULT = -1;
32+
private static final NumberTextFormField maxAge = new NumberTextFormField(CONFIG_AGE, //
33+
"Max token age in days", //
34+
"After this amount of days the API token will be overwritten and the user must renew it interactively. Setting this to 0 or a negative value disables max token age entirely. Default is "
35+
+ CONFIG_AGE_DEFAULT + " days.",
36+
FormField.MANDATORY)//
37+
.withInitialValue(CONFIG_AGE_DEFAULT);
38+
39+
public static final String NOTIFY = TYPE_ID + "-notify";
40+
public static final Boolean NOTIFY_DEFAULT = false;
41+
private static final CheckboxFormField notify = new CheckboxFormField(NOTIFY, //
42+
"Send Email on token invalidation", //
43+
"Defines whether an email is send to the affected user if their API token is invalidated automatically based on any condition. Default is "
44+
+ NOTIFY_DEFAULT,
45+
FormField.OPTIONAL)//
46+
.withInitialValue(NOTIFY_DEFAULT);
2547

2648
@Inject
2749
public OAuth2ProxyApiTokenInvalidateTaskDescriptor() {
2850
super(TYPE_ID, OAuth2ProxyApiTokenInvalidateTask.class, "OAuth2 Proxy API token invalidator",
29-
TaskDescriptorSupport.VISIBLE, TaskDescriptorSupport.EXPOSED,
30-
TaskDescriptorSupport.REQUEST_RECOVERY, new FormField[] { field });
51+
TaskDescriptorSupport.VISIBLE, TaskDescriptorSupport.EXPOSED, TaskDescriptorSupport.REQUEST_RECOVERY,
52+
new FormField[] { maxIdleAge, maxAge, notify });
3153
}
3254

3355
@Override

src/main/java/com/github/tumbl3w33d/h2/OAuth2ProxyLoginRecordStore.java

Lines changed: 8 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,8 +2,10 @@
22

33
import static com.github.tumbl3w33d.h2.OAuth2ProxyStores.loginRecordDAO;
44

5+
import java.util.Collections;
6+
import java.util.Map;
57
import java.util.Optional;
6-
import java.util.Set;
8+
import java.util.function.Function;
79
import java.util.stream.Collectors;
810
import java.util.stream.StreamSupport;
911

@@ -18,7 +20,6 @@
1820
import org.sonatype.nexus.transaction.TransactionalStore;
1921

2022
import com.github.tumbl3w33d.users.db.OAuth2ProxyLoginRecord;
21-
import com.google.common.collect.ImmutableSet;
2223

2324
@Named("mybatis")
2425
@Singleton
@@ -28,8 +29,7 @@ public class OAuth2ProxyLoginRecordStore extends StateGuardLifecycleSupport
2829
private final DataSessionSupplier sessionSupplier;
2930

3031
@Inject
31-
public OAuth2ProxyLoginRecordStore(
32-
final DataSessionSupplier sessionSupplier) {
32+
public OAuth2ProxyLoginRecordStore(final DataSessionSupplier sessionSupplier) {
3333
this.sessionSupplier = sessionSupplier;
3434
}
3535

@@ -39,19 +39,17 @@ public DataSession<?> openSession() {
3939
}
4040

4141
@Transactional
42-
public Set<OAuth2ProxyLoginRecord> getAllLoginRecords() {
43-
Set<OAuth2ProxyLoginRecord> allRecords = StreamSupport.stream(loginRecordDAO().browse().spliterator(), false)
44-
.collect(Collectors.toSet());
45-
46-
return ImmutableSet.copyOf(allRecords);
42+
public Map<String, OAuth2ProxyLoginRecord> getAllLoginRecords() {
43+
return Collections.unmodifiableMap(StreamSupport.stream(loginRecordDAO().browse().spliterator(), false)
44+
.collect(Collectors.toMap(OAuth2ProxyLoginRecord::getId, Function.identity())));
4745
}
4846

4947
@Transactional
5048
public Optional<OAuth2ProxyLoginRecord> getLoginRecord(String userId) {
5149
log.trace("call to getLoginRecord with userId {}", userId);
5250

5351
try {
54-
return OAuth2ProxyStores.loginRecordDAO().read(userId);
52+
return loginRecordDAO().read(userId);
5553
} catch (Exception e) {
5654
log.error("unable to retrieve login record for {} - {}", userId, e);
5755
throw e;

src/main/java/com/github/tumbl3w33d/h2/OAuth2ProxyStores.java

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,4 +32,8 @@ public static OAuth2ProxyRoleDAO roleDAO() {
3232
public static OAuth2ProxyLoginRecordDAO loginRecordDAO() {
3333
return dao(OAuth2ProxyLoginRecordDAO.class);
3434
}
35+
36+
public static OAuth2ProxyTokenInfoDAO tokenInfoDAO() {
37+
return dao(OAuth2ProxyTokenInfoDAO.class);
38+
}
3539
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
package com.github.tumbl3w33d.h2;
2+
3+
import org.sonatype.nexus.datastore.api.IdentifiedDataAccess;
4+
5+
import com.github.tumbl3w33d.users.db.OAuth2ProxyTokenInfo;
6+
7+
public interface OAuth2ProxyTokenInfoDAO extends IdentifiedDataAccess<OAuth2ProxyTokenInfo> {
8+
9+
}

0 commit comments

Comments
 (0)